diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abfe1ccac..55bfcf5c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ -# 0.7.0-rc.66 (Synonym Fork) +# 0.7.0-rc.67 (Synonym Fork) ## Bug Fixes +- `list_pending_broadcasts` now returns each unresolved spend's complete RBF lineage so callers can + independently reconcile every replacement before `abandon_pending_broadcast`. +- Explicit on-chain sends now return a transaction ID only after the configured backend accepts + the transaction. Transaction-keyed rejected, not-dispatched, acceptance-unknown failure, and + acceptance-unknown timeout outcomes are distinct. Uncertain signed transactions remain reserved + in a durable intent store for enumeration, exact-byte rebroadcast, sync reconciliation, and + recovery after restart. Callers can explicitly abandon an externally reconciled intent, and RBF + replacements use the same result-bearing, durable lifecycle while exposing only the canonical + transaction in payment history. +- `NodeError` is now a fielded mobile error type so broadcast failures can expose their transaction + ID. Swift error cases no longer contain the legacy generated `message` associated value, and + fieldless Kotlin exceptions have an empty generated `message`; callers should match the error + variant and use its typed fields. +- Electrum transaction rejections are now logged as failures instead of successful broadcasts. - Prevent native SIGABRT crashes when stopping and rebuilding the node by making runtime teardown deterministic. - 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. 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..fafa771026 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 = "f16f94119be8df627fec4d974f911339ccf74c9b6961feec66841e338f5d4683" 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 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/jniLibs/arm64-v8a/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so index d6447f3f2e..33f22b3898 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/arm64-v8a/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so index 5d503a817a..f4123973ee 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/armeabi-v7a/libldk_node.so differ diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so index a432cf6f4b..fa94b9e45f 100755 Binary files a/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so and b/bindings/kotlin/ldk-node-android/lib/src/main/jniLibs/x86_64/libldk_node.so differ 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..6ef1884f68 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 @@ -1547,6 +1547,12 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + + + @@ -2610,6 +2616,11 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): Unit + fun uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit fun uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp( `ptr`: Pointer?, `txid`: RustBufferByValue, @@ -2676,6 +2687,10 @@ internal interface UniffiLib : Library { `utxosToSpend`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): Long + fun uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( + `ptr`: Pointer?, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs( `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, @@ -2710,6 +2725,11 @@ internal interface UniffiLib : Library { `addressType`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to( `ptr`: Pointer?, `addressType`: RustBufferByValue, @@ -3466,6 +3486,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_offer_supports_chain( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index( @@ -3484,6 +3506,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address( @@ -3498,6 +3522,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to_account( @@ -4093,6 +4119,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_offer_supports_chain() != 2135.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4120,6 +4149,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts() != 40346.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4141,6 +4173,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type() != 62171.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction() != 36642.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to() != 44189.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8337,6 +8372,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { } + @Throws(NodeException::class) + override fun `abandonPendingBroadcast`(`txid`: Txid) { + callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + } + } + @Throws(NodeException::class) override fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -8475,6 +8523,18 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `listPendingBroadcasts`(): List { + return FfiConverterSequenceTypePendingBroadcastInfo.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( + it, + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `listSpendableOutputs`(): List { return FfiConverterSequenceTypeSpendableUtxo.lift(callWithPointer { @@ -8565,6 +8625,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `rebroadcastTransaction`(`txid`: Txid): Txid { + return FfiConverterTypeTxid.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) { callWithPointer { @@ -10501,6 +10574,28 @@ object FfiConverterTypePeerDetails: FfiConverterRustBuffer { +object FfiConverterTypePendingBroadcastInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PendingBroadcastInfo { + return PendingBroadcastInfo( + FfiConverterTypeTxid.read(buf), + FfiConverterSequenceTypeTxid.read(buf), + ) + } + + override fun allocationSize(value: PendingBroadcastInfo) = ( + FfiConverterTypeTxid.allocationSize(value.`txid`) + + FfiConverterSequenceTypeTxid.allocationSize(value.`lineage`) + ) + + override fun write(value: PendingBroadcastInfo, buf: ByteBuffer) { + FfiConverterTypeTxid.write(value.`txid`, buf) + FfiConverterSequenceTypeTxid.write(value.`lineage`, buf) + } +} + + + + object FfiConverterTypeProbeHandle: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProbeHandle { return ProbeHandle( @@ -12132,80 +12227,385 @@ object NodeExceptionErrorHandler : UniffiRustCallStatusErrorHandler { override fun read(buf: ByteBuffer): NodeException { return when (buf.getInt()) { - 1 -> NodeException.AlreadyRunning(FfiConverterString.read(buf)) - 2 -> NodeException.NotRunning(FfiConverterString.read(buf)) - 3 -> NodeException.OnchainTxCreationFailed(FfiConverterString.read(buf)) - 4 -> NodeException.ConnectionFailed(FfiConverterString.read(buf)) - 5 -> NodeException.InvoiceCreationFailed(FfiConverterString.read(buf)) - 6 -> NodeException.InvoiceRequestCreationFailed(FfiConverterString.read(buf)) - 7 -> NodeException.OfferCreationFailed(FfiConverterString.read(buf)) - 8 -> NodeException.RefundCreationFailed(FfiConverterString.read(buf)) - 9 -> NodeException.PaymentSendingFailed(FfiConverterString.read(buf)) - 10 -> NodeException.InvalidCustomTlvs(FfiConverterString.read(buf)) - 11 -> NodeException.ProbeSendingFailed(FfiConverterString.read(buf)) - 12 -> NodeException.RouteNotFound(FfiConverterString.read(buf)) - 13 -> NodeException.ChannelCreationFailed(FfiConverterString.read(buf)) - 14 -> NodeException.ChannelClosingFailed(FfiConverterString.read(buf)) - 15 -> NodeException.ChannelSplicingFailed(FfiConverterString.read(buf)) - 16 -> NodeException.ChannelConfigUpdateFailed(FfiConverterString.read(buf)) - 17 -> NodeException.PersistenceFailed(FfiConverterString.read(buf)) - 18 -> NodeException.FeerateEstimationUpdateFailed(FfiConverterString.read(buf)) - 19 -> NodeException.FeerateEstimationUpdateTimeout(FfiConverterString.read(buf)) - 20 -> NodeException.WalletOperationFailed(FfiConverterString.read(buf)) - 21 -> NodeException.WalletOperationTimeout(FfiConverterString.read(buf)) - 22 -> NodeException.OnchainTxSigningFailed(FfiConverterString.read(buf)) - 23 -> NodeException.TxSyncFailed(FfiConverterString.read(buf)) - 24 -> NodeException.TxSyncTimeout(FfiConverterString.read(buf)) - 25 -> NodeException.GossipUpdateFailed(FfiConverterString.read(buf)) - 26 -> NodeException.GossipUpdateTimeout(FfiConverterString.read(buf)) - 27 -> NodeException.LiquidityRequestFailed(FfiConverterString.read(buf)) - 28 -> NodeException.UriParameterParsingFailed(FfiConverterString.read(buf)) - 29 -> NodeException.InvalidAddress(FfiConverterString.read(buf)) - 30 -> NodeException.InvalidSocketAddress(FfiConverterString.read(buf)) - 31 -> NodeException.InvalidPublicKey(FfiConverterString.read(buf)) - 32 -> NodeException.InvalidSecretKey(FfiConverterString.read(buf)) - 33 -> NodeException.InvalidOfferId(FfiConverterString.read(buf)) - 34 -> NodeException.InvalidNodeId(FfiConverterString.read(buf)) - 35 -> NodeException.InvalidPaymentId(FfiConverterString.read(buf)) - 36 -> NodeException.InvalidPaymentHash(FfiConverterString.read(buf)) - 37 -> NodeException.InvalidPaymentPreimage(FfiConverterString.read(buf)) - 38 -> NodeException.InvalidPaymentSecret(FfiConverterString.read(buf)) - 39 -> NodeException.InvalidAmount(FfiConverterString.read(buf)) - 40 -> NodeException.InvalidInvoice(FfiConverterString.read(buf)) - 41 -> NodeException.InvalidOffer(FfiConverterString.read(buf)) - 42 -> NodeException.InvalidRefund(FfiConverterString.read(buf)) - 43 -> NodeException.InvalidChannelId(FfiConverterString.read(buf)) - 44 -> NodeException.InvalidNetwork(FfiConverterString.read(buf)) - 45 -> NodeException.InvalidUri(FfiConverterString.read(buf)) - 46 -> NodeException.InvalidQuantity(FfiConverterString.read(buf)) - 47 -> NodeException.InvalidNodeAlias(FfiConverterString.read(buf)) - 48 -> NodeException.InvalidDateTime(FfiConverterString.read(buf)) - 49 -> NodeException.InvalidFeeRate(FfiConverterString.read(buf)) - 50 -> NodeException.DuplicatePayment(FfiConverterString.read(buf)) - 51 -> NodeException.UnsupportedCurrency(FfiConverterString.read(buf)) - 52 -> NodeException.InsufficientFunds(FfiConverterString.read(buf)) - 53 -> NodeException.LiquiditySourceUnavailable(FfiConverterString.read(buf)) - 54 -> NodeException.LiquidityFeeTooHigh(FfiConverterString.read(buf)) - 55 -> NodeException.InvalidBlindedPaths(FfiConverterString.read(buf)) - 56 -> NodeException.AsyncPaymentServicesDisabled(FfiConverterString.read(buf)) - 57 -> NodeException.CannotRbfFundingTransaction(FfiConverterString.read(buf)) - 58 -> NodeException.TransactionNotFound(FfiConverterString.read(buf)) - 59 -> NodeException.TransactionAlreadyConfirmed(FfiConverterString.read(buf)) - 60 -> NodeException.NoSpendableOutputs(FfiConverterString.read(buf)) - 61 -> NodeException.CoinSelectionFailed(FfiConverterString.read(buf)) - 62 -> NodeException.InvalidMnemonic(FfiConverterString.read(buf)) - 63 -> NodeException.BackgroundSyncNotEnabled(FfiConverterString.read(buf)) - 64 -> NodeException.AddressTypeAlreadyMonitored(FfiConverterString.read(buf)) - 65 -> NodeException.AddressTypeIsPrimary(FfiConverterString.read(buf)) - 66 -> NodeException.AddressTypeNotMonitored(FfiConverterString.read(buf)) - 67 -> NodeException.OnchainWalletAccountNotRegistered(FfiConverterString.read(buf)) - 68 -> NodeException.InvalidSeedBytes(FfiConverterString.read(buf)) + 1 -> NodeException.AlreadyRunning() + 2 -> NodeException.NotRunning() + 3 -> NodeException.OnchainTxCreationFailed() + 4 -> NodeException.ConnectionFailed() + 5 -> NodeException.InvoiceCreationFailed() + 6 -> NodeException.InvoiceRequestCreationFailed() + 7 -> NodeException.OfferCreationFailed() + 8 -> NodeException.RefundCreationFailed() + 9 -> NodeException.PaymentSendingFailed() + 10 -> NodeException.InvalidCustomTlvs() + 11 -> NodeException.ProbeSendingFailed() + 12 -> NodeException.RouteNotFound() + 13 -> NodeException.ChannelCreationFailed() + 14 -> NodeException.ChannelClosingFailed() + 15 -> NodeException.ChannelSplicingFailed() + 16 -> NodeException.ChannelConfigUpdateFailed() + 17 -> NodeException.PersistenceFailed() + 18 -> NodeException.FeerateEstimationUpdateFailed() + 19 -> NodeException.FeerateEstimationUpdateTimeout() + 20 -> NodeException.WalletOperationFailed() + 21 -> NodeException.WalletOperationTimeout() + 22 -> NodeException.OnchainTxSigningFailed() + 23 -> NodeException.TxSyncFailed() + 24 -> NodeException.TxSyncTimeout() + 25 -> NodeException.GossipUpdateFailed() + 26 -> NodeException.GossipUpdateTimeout() + 27 -> NodeException.LiquidityRequestFailed() + 28 -> NodeException.UriParameterParsingFailed() + 29 -> NodeException.InvalidAddress() + 30 -> NodeException.InvalidSocketAddress() + 31 -> NodeException.InvalidPublicKey() + 32 -> NodeException.InvalidSecretKey() + 33 -> NodeException.InvalidOfferId() + 34 -> NodeException.InvalidNodeId() + 35 -> NodeException.InvalidPaymentId() + 36 -> NodeException.InvalidPaymentHash() + 37 -> NodeException.InvalidPaymentPreimage() + 38 -> NodeException.InvalidPaymentSecret() + 39 -> NodeException.InvalidAmount() + 40 -> NodeException.InvalidInvoice() + 41 -> NodeException.InvalidOffer() + 42 -> NodeException.InvalidRefund() + 43 -> NodeException.InvalidChannelId() + 44 -> NodeException.InvalidNetwork() + 45 -> NodeException.InvalidUri() + 46 -> NodeException.InvalidQuantity() + 47 -> NodeException.InvalidNodeAlias() + 48 -> NodeException.InvalidDateTime() + 49 -> NodeException.InvalidFeeRate() + 50 -> NodeException.DuplicatePayment() + 51 -> NodeException.UnsupportedCurrency() + 52 -> NodeException.InsufficientFunds() + 53 -> NodeException.LiquiditySourceUnavailable() + 54 -> NodeException.LiquidityFeeTooHigh() + 55 -> NodeException.InvalidBlindedPaths() + 56 -> NodeException.AsyncPaymentServicesDisabled() + 57 -> NodeException.CannotRbfFundingTransaction() + 58 -> NodeException.TransactionNotFound() + 59 -> NodeException.TransactionAlreadyConfirmed() + 60 -> NodeException.NoSpendableOutputs() + 61 -> NodeException.CoinSelectionFailed() + 62 -> NodeException.InvalidMnemonic() + 63 -> NodeException.BackgroundSyncNotEnabled() + 64 -> NodeException.AddressTypeAlreadyMonitored() + 65 -> NodeException.AddressTypeIsPrimary() + 66 -> NodeException.AddressTypeNotMonitored() + 67 -> NodeException.OnchainWalletAccountNotRegistered() + 68 -> NodeException.InvalidSeedBytes() + 69 -> NodeException.OnchainTxBroadcastRejected( + FfiConverterTypeTxid.read(buf), + ) + 70 -> NodeException.OnchainTxBroadcastFailed( + FfiConverterTypeTxid.read(buf), + ) + 71 -> NodeException.OnchainTxBroadcastTimeout( + FfiConverterTypeTxid.read(buf), + ) + 72 -> NodeException.OnchainTxBroadcastNotDispatched( + FfiConverterTypeTxid.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } override fun allocationSize(value: NodeException): ULong { - return 4UL + return when (value) { + is NodeException.AlreadyRunning -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.NotRunning -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainTxCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ConnectionFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvoiceCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvoiceRequestCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OfferCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.RefundCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.PaymentSendingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidCustomTlvs -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ProbeSendingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.RouteNotFound -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelClosingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelSplicingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelConfigUpdateFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.PersistenceFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.FeerateEstimationUpdateFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.FeerateEstimationUpdateTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.WalletOperationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.WalletOperationTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainTxSigningFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TxSyncFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TxSyncTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.GossipUpdateFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.GossipUpdateTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.LiquidityRequestFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.UriParameterParsingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidAddress -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidSocketAddress -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPublicKey -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidSecretKey -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidOfferId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidNodeId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentHash -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentPreimage -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentSecret -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidAmount -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidInvoice -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidOffer -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidRefund -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidChannelId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidNetwork -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidUri -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidQuantity -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidNodeAlias -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidDateTime -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidFeeRate -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.DuplicatePayment -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.UnsupportedCurrency -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InsufficientFunds -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.LiquiditySourceUnavailable -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.LiquidityFeeTooHigh -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidBlindedPaths -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AsyncPaymentServicesDisabled -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.CannotRbfFundingTransaction -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TransactionNotFound -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TransactionAlreadyConfirmed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.NoSpendableOutputs -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.CoinSelectionFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidMnemonic -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.BackgroundSyncNotEnabled -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AddressTypeAlreadyMonitored -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AddressTypeIsPrimary -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AddressTypeNotMonitored -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainWalletAccountNotRegistered -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidSeedBytes -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainTxBroadcastRejected -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + is NodeException.OnchainTxBroadcastFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + is NodeException.OnchainTxBroadcastTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + is NodeException.OnchainTxBroadcastNotDispatched -> ( + // 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: NodeException, buf: ByteBuffer) { @@ -12482,6 +12882,26 @@ object FfiConverterTypeNodeError : FfiConverterRustBuffer { buf.putInt(68) Unit } + is NodeException.OnchainTxBroadcastRejected -> { + buf.putInt(69) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is NodeException.OnchainTxBroadcastFailed -> { + buf.putInt(70) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is NodeException.OnchainTxBroadcastTimeout -> { + buf.putInt(71) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is NodeException.OnchainTxBroadcastNotDispatched -> { + buf.putInt(72) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -14587,6 +15007,31 @@ object FfiConverterSequenceTypePeerDetails: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypePendingBroadcastInfo.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.sumOf { FfiConverterTypePendingBroadcastInfo.allocationSize(it) } + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypePendingBroadcastInfo.write(it, buf) + } + } +} + + + + object FfiConverterSequenceTypeProbeHandle: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() 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..5c5bada1f2 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 @@ -593,6 +593,9 @@ interface OfferInterface { interface OnchainPaymentInterface { + @Throws(NodeException::class) + fun `abandonPendingBroadcast`(`txid`: Txid) + @Throws(NodeException::class) fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid @@ -620,6 +623,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong + @Throws(NodeException::class) + fun `listPendingBroadcasts`(): List + @Throws(NodeException::class) fun `listSpendableOutputs`(): List @@ -641,6 +647,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `newAddressInfoForType`(`addressType`: AddressType): AddressInfo + @Throws(NodeException::class) + fun `rebroadcastTransaction`(`txid`: Txid): Txid + @Throws(NodeException::class) fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) @@ -1197,6 +1206,16 @@ data class PeerDetails ( +@kotlinx.serialization.Serializable +data class PendingBroadcastInfo ( + val `txid`: Txid, + val `lineage`: List +) { + companion object +} + + + @kotlinx.serialization.Serializable data class ProbeHandle ( val `paymentHash`: PaymentHash, @@ -1887,143 +1906,443 @@ enum class Network { -sealed class NodeException(message: String): kotlin.Exception(message) { +sealed class NodeException: kotlin.Exception() { + + class AlreadyRunning( + ) : NodeException() { + override val message + get() = "" + } + + class NotRunning( + ) : NodeException() { + override val message + get() = "" + } + + class OnchainTxCreationFailed( + ) : NodeException() { + override val message + get() = "" + } + + class ConnectionFailed( + ) : NodeException() { + override val message + get() = "" + } - class AlreadyRunning(message: String) : NodeException(message) + class InvoiceCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class NotRunning(message: String) : NodeException(message) + class InvoiceRequestCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class OnchainTxCreationFailed(message: String) : NodeException(message) + class OfferCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class ConnectionFailed(message: String) : NodeException(message) + class RefundCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvoiceCreationFailed(message: String) : NodeException(message) + class PaymentSendingFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvoiceRequestCreationFailed(message: String) : NodeException(message) + class InvalidCustomTlvs( + ) : NodeException() { + override val message + get() = "" + } - class OfferCreationFailed(message: String) : NodeException(message) + class ProbeSendingFailed( + ) : NodeException() { + override val message + get() = "" + } - class RefundCreationFailed(message: String) : NodeException(message) + class RouteNotFound( + ) : NodeException() { + override val message + get() = "" + } - class PaymentSendingFailed(message: String) : NodeException(message) + class ChannelCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvalidCustomTlvs(message: String) : NodeException(message) + class ChannelClosingFailed( + ) : NodeException() { + override val message + get() = "" + } - class ProbeSendingFailed(message: String) : NodeException(message) + class ChannelSplicingFailed( + ) : NodeException() { + override val message + get() = "" + } - class RouteNotFound(message: String) : NodeException(message) + class ChannelConfigUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelCreationFailed(message: String) : NodeException(message) + class PersistenceFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelClosingFailed(message: String) : NodeException(message) + class FeerateEstimationUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelSplicingFailed(message: String) : NodeException(message) + class FeerateEstimationUpdateTimeout( + ) : NodeException() { + override val message + get() = "" + } - class ChannelConfigUpdateFailed(message: String) : NodeException(message) + class WalletOperationFailed( + ) : NodeException() { + override val message + get() = "" + } - class PersistenceFailed(message: String) : NodeException(message) + class WalletOperationTimeout( + ) : NodeException() { + override val message + get() = "" + } - class FeerateEstimationUpdateFailed(message: String) : NodeException(message) + class OnchainTxSigningFailed( + ) : NodeException() { + override val message + get() = "" + } - class FeerateEstimationUpdateTimeout(message: String) : NodeException(message) + class TxSyncFailed( + ) : NodeException() { + override val message + get() = "" + } - class WalletOperationFailed(message: String) : NodeException(message) + class TxSyncTimeout( + ) : NodeException() { + override val message + get() = "" + } - class WalletOperationTimeout(message: String) : NodeException(message) + class GossipUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class OnchainTxSigningFailed(message: String) : NodeException(message) + class GossipUpdateTimeout( + ) : NodeException() { + override val message + get() = "" + } - class TxSyncFailed(message: String) : NodeException(message) + class LiquidityRequestFailed( + ) : NodeException() { + override val message + get() = "" + } - class TxSyncTimeout(message: String) : NodeException(message) + class UriParameterParsingFailed( + ) : NodeException() { + override val message + get() = "" + } - class GossipUpdateFailed(message: String) : NodeException(message) + class InvalidAddress( + ) : NodeException() { + override val message + get() = "" + } - class GossipUpdateTimeout(message: String) : NodeException(message) + class InvalidSocketAddress( + ) : NodeException() { + override val message + get() = "" + } - class LiquidityRequestFailed(message: String) : NodeException(message) + class InvalidPublicKey( + ) : NodeException() { + override val message + get() = "" + } - class UriParameterParsingFailed(message: String) : NodeException(message) + class InvalidSecretKey( + ) : NodeException() { + override val message + get() = "" + } - class InvalidAddress(message: String) : NodeException(message) + class InvalidOfferId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSocketAddress(message: String) : NodeException(message) + class InvalidNodeId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPublicKey(message: String) : NodeException(message) + class InvalidPaymentId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSecretKey(message: String) : NodeException(message) + class InvalidPaymentHash( + ) : NodeException() { + override val message + get() = "" + } - class InvalidOfferId(message: String) : NodeException(message) + class InvalidPaymentPreimage( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNodeId(message: String) : NodeException(message) + class InvalidPaymentSecret( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentId(message: String) : NodeException(message) + class InvalidAmount( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentHash(message: String) : NodeException(message) + class InvalidInvoice( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentPreimage(message: String) : NodeException(message) + class InvalidOffer( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentSecret(message: String) : NodeException(message) + class InvalidRefund( + ) : NodeException() { + override val message + get() = "" + } - class InvalidAmount(message: String) : NodeException(message) + class InvalidChannelId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidInvoice(message: String) : NodeException(message) + class InvalidNetwork( + ) : NodeException() { + override val message + get() = "" + } - class InvalidOffer(message: String) : NodeException(message) + class InvalidUri( + ) : NodeException() { + override val message + get() = "" + } - class InvalidRefund(message: String) : NodeException(message) + class InvalidQuantity( + ) : NodeException() { + override val message + get() = "" + } - class InvalidChannelId(message: String) : NodeException(message) + class InvalidNodeAlias( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNetwork(message: String) : NodeException(message) + class InvalidDateTime( + ) : NodeException() { + override val message + get() = "" + } - class InvalidUri(message: String) : NodeException(message) + class InvalidFeeRate( + ) : NodeException() { + override val message + get() = "" + } - class InvalidQuantity(message: String) : NodeException(message) + class DuplicatePayment( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNodeAlias(message: String) : NodeException(message) + class UnsupportedCurrency( + ) : NodeException() { + override val message + get() = "" + } - class InvalidDateTime(message: String) : NodeException(message) + class InsufficientFunds( + ) : NodeException() { + override val message + get() = "" + } - class InvalidFeeRate(message: String) : NodeException(message) + class LiquiditySourceUnavailable( + ) : NodeException() { + override val message + get() = "" + } - class DuplicatePayment(message: String) : NodeException(message) + class LiquidityFeeTooHigh( + ) : NodeException() { + override val message + get() = "" + } - class UnsupportedCurrency(message: String) : NodeException(message) + class InvalidBlindedPaths( + ) : NodeException() { + override val message + get() = "" + } - class InsufficientFunds(message: String) : NodeException(message) + class AsyncPaymentServicesDisabled( + ) : NodeException() { + override val message + get() = "" + } - class LiquiditySourceUnavailable(message: String) : NodeException(message) + class CannotRbfFundingTransaction( + ) : NodeException() { + override val message + get() = "" + } - class LiquidityFeeTooHigh(message: String) : NodeException(message) + class TransactionNotFound( + ) : NodeException() { + override val message + get() = "" + } - class InvalidBlindedPaths(message: String) : NodeException(message) + class TransactionAlreadyConfirmed( + ) : NodeException() { + override val message + get() = "" + } - class AsyncPaymentServicesDisabled(message: String) : NodeException(message) + class NoSpendableOutputs( + ) : NodeException() { + override val message + get() = "" + } - class CannotRbfFundingTransaction(message: String) : NodeException(message) + class CoinSelectionFailed( + ) : NodeException() { + override val message + get() = "" + } - class TransactionNotFound(message: String) : NodeException(message) + class InvalidMnemonic( + ) : NodeException() { + override val message + get() = "" + } - class TransactionAlreadyConfirmed(message: String) : NodeException(message) + class BackgroundSyncNotEnabled( + ) : NodeException() { + override val message + get() = "" + } - class NoSpendableOutputs(message: String) : NodeException(message) + class AddressTypeAlreadyMonitored( + ) : NodeException() { + override val message + get() = "" + } - class CoinSelectionFailed(message: String) : NodeException(message) + class AddressTypeIsPrimary( + ) : NodeException() { + override val message + get() = "" + } - class InvalidMnemonic(message: String) : NodeException(message) + class AddressTypeNotMonitored( + ) : NodeException() { + override val message + get() = "" + } - class BackgroundSyncNotEnabled(message: String) : NodeException(message) + class OnchainWalletAccountNotRegistered( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeAlreadyMonitored(message: String) : NodeException(message) + class InvalidSeedBytes( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeIsPrimary(message: String) : NodeException(message) + class OnchainTxBroadcastRejected( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class AddressTypeNotMonitored(message: String) : NodeException(message) + class OnchainTxBroadcastFailed( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class OnchainWalletAccountNotRegistered(message: String) : NodeException(message) + class OnchainTxBroadcastTimeout( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class InvalidSeedBytes(message: String) : NodeException(message) + class OnchainTxBroadcastNotDispatched( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } } @@ -2411,6 +2730,8 @@ enum class WordCount { + + 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/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index c51632c07a..5c5bada1f2 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -593,6 +593,9 @@ interface OfferInterface { interface OnchainPaymentInterface { + @Throws(NodeException::class) + fun `abandonPendingBroadcast`(`txid`: Txid) + @Throws(NodeException::class) fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid @@ -620,6 +623,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong + @Throws(NodeException::class) + fun `listPendingBroadcasts`(): List + @Throws(NodeException::class) fun `listSpendableOutputs`(): List @@ -641,6 +647,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `newAddressInfoForType`(`addressType`: AddressType): AddressInfo + @Throws(NodeException::class) + fun `rebroadcastTransaction`(`txid`: Txid): Txid + @Throws(NodeException::class) fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) @@ -1197,6 +1206,16 @@ data class PeerDetails ( +@kotlinx.serialization.Serializable +data class PendingBroadcastInfo ( + val `txid`: Txid, + val `lineage`: List +) { + companion object +} + + + @kotlinx.serialization.Serializable data class ProbeHandle ( val `paymentHash`: PaymentHash, @@ -1887,143 +1906,443 @@ enum class Network { -sealed class NodeException(message: String): kotlin.Exception(message) { +sealed class NodeException: kotlin.Exception() { + + class AlreadyRunning( + ) : NodeException() { + override val message + get() = "" + } + + class NotRunning( + ) : NodeException() { + override val message + get() = "" + } + + class OnchainTxCreationFailed( + ) : NodeException() { + override val message + get() = "" + } + + class ConnectionFailed( + ) : NodeException() { + override val message + get() = "" + } - class AlreadyRunning(message: String) : NodeException(message) + class InvoiceCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class NotRunning(message: String) : NodeException(message) + class InvoiceRequestCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class OnchainTxCreationFailed(message: String) : NodeException(message) + class OfferCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class ConnectionFailed(message: String) : NodeException(message) + class RefundCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvoiceCreationFailed(message: String) : NodeException(message) + class PaymentSendingFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvoiceRequestCreationFailed(message: String) : NodeException(message) + class InvalidCustomTlvs( + ) : NodeException() { + override val message + get() = "" + } - class OfferCreationFailed(message: String) : NodeException(message) + class ProbeSendingFailed( + ) : NodeException() { + override val message + get() = "" + } - class RefundCreationFailed(message: String) : NodeException(message) + class RouteNotFound( + ) : NodeException() { + override val message + get() = "" + } - class PaymentSendingFailed(message: String) : NodeException(message) + class ChannelCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvalidCustomTlvs(message: String) : NodeException(message) + class ChannelClosingFailed( + ) : NodeException() { + override val message + get() = "" + } - class ProbeSendingFailed(message: String) : NodeException(message) + class ChannelSplicingFailed( + ) : NodeException() { + override val message + get() = "" + } - class RouteNotFound(message: String) : NodeException(message) + class ChannelConfigUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelCreationFailed(message: String) : NodeException(message) + class PersistenceFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelClosingFailed(message: String) : NodeException(message) + class FeerateEstimationUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelSplicingFailed(message: String) : NodeException(message) + class FeerateEstimationUpdateTimeout( + ) : NodeException() { + override val message + get() = "" + } - class ChannelConfigUpdateFailed(message: String) : NodeException(message) + class WalletOperationFailed( + ) : NodeException() { + override val message + get() = "" + } - class PersistenceFailed(message: String) : NodeException(message) + class WalletOperationTimeout( + ) : NodeException() { + override val message + get() = "" + } - class FeerateEstimationUpdateFailed(message: String) : NodeException(message) + class OnchainTxSigningFailed( + ) : NodeException() { + override val message + get() = "" + } - class FeerateEstimationUpdateTimeout(message: String) : NodeException(message) + class TxSyncFailed( + ) : NodeException() { + override val message + get() = "" + } - class WalletOperationFailed(message: String) : NodeException(message) + class TxSyncTimeout( + ) : NodeException() { + override val message + get() = "" + } - class WalletOperationTimeout(message: String) : NodeException(message) + class GossipUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class OnchainTxSigningFailed(message: String) : NodeException(message) + class GossipUpdateTimeout( + ) : NodeException() { + override val message + get() = "" + } - class TxSyncFailed(message: String) : NodeException(message) + class LiquidityRequestFailed( + ) : NodeException() { + override val message + get() = "" + } - class TxSyncTimeout(message: String) : NodeException(message) + class UriParameterParsingFailed( + ) : NodeException() { + override val message + get() = "" + } - class GossipUpdateFailed(message: String) : NodeException(message) + class InvalidAddress( + ) : NodeException() { + override val message + get() = "" + } - class GossipUpdateTimeout(message: String) : NodeException(message) + class InvalidSocketAddress( + ) : NodeException() { + override val message + get() = "" + } - class LiquidityRequestFailed(message: String) : NodeException(message) + class InvalidPublicKey( + ) : NodeException() { + override val message + get() = "" + } - class UriParameterParsingFailed(message: String) : NodeException(message) + class InvalidSecretKey( + ) : NodeException() { + override val message + get() = "" + } - class InvalidAddress(message: String) : NodeException(message) + class InvalidOfferId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSocketAddress(message: String) : NodeException(message) + class InvalidNodeId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPublicKey(message: String) : NodeException(message) + class InvalidPaymentId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSecretKey(message: String) : NodeException(message) + class InvalidPaymentHash( + ) : NodeException() { + override val message + get() = "" + } - class InvalidOfferId(message: String) : NodeException(message) + class InvalidPaymentPreimage( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNodeId(message: String) : NodeException(message) + class InvalidPaymentSecret( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentId(message: String) : NodeException(message) + class InvalidAmount( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentHash(message: String) : NodeException(message) + class InvalidInvoice( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentPreimage(message: String) : NodeException(message) + class InvalidOffer( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentSecret(message: String) : NodeException(message) + class InvalidRefund( + ) : NodeException() { + override val message + get() = "" + } - class InvalidAmount(message: String) : NodeException(message) + class InvalidChannelId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidInvoice(message: String) : NodeException(message) + class InvalidNetwork( + ) : NodeException() { + override val message + get() = "" + } - class InvalidOffer(message: String) : NodeException(message) + class InvalidUri( + ) : NodeException() { + override val message + get() = "" + } - class InvalidRefund(message: String) : NodeException(message) + class InvalidQuantity( + ) : NodeException() { + override val message + get() = "" + } - class InvalidChannelId(message: String) : NodeException(message) + class InvalidNodeAlias( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNetwork(message: String) : NodeException(message) + class InvalidDateTime( + ) : NodeException() { + override val message + get() = "" + } - class InvalidUri(message: String) : NodeException(message) + class InvalidFeeRate( + ) : NodeException() { + override val message + get() = "" + } - class InvalidQuantity(message: String) : NodeException(message) + class DuplicatePayment( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNodeAlias(message: String) : NodeException(message) + class UnsupportedCurrency( + ) : NodeException() { + override val message + get() = "" + } - class InvalidDateTime(message: String) : NodeException(message) + class InsufficientFunds( + ) : NodeException() { + override val message + get() = "" + } - class InvalidFeeRate(message: String) : NodeException(message) + class LiquiditySourceUnavailable( + ) : NodeException() { + override val message + get() = "" + } - class DuplicatePayment(message: String) : NodeException(message) + class LiquidityFeeTooHigh( + ) : NodeException() { + override val message + get() = "" + } - class UnsupportedCurrency(message: String) : NodeException(message) + class InvalidBlindedPaths( + ) : NodeException() { + override val message + get() = "" + } - class InsufficientFunds(message: String) : NodeException(message) + class AsyncPaymentServicesDisabled( + ) : NodeException() { + override val message + get() = "" + } - class LiquiditySourceUnavailable(message: String) : NodeException(message) + class CannotRbfFundingTransaction( + ) : NodeException() { + override val message + get() = "" + } - class LiquidityFeeTooHigh(message: String) : NodeException(message) + class TransactionNotFound( + ) : NodeException() { + override val message + get() = "" + } - class InvalidBlindedPaths(message: String) : NodeException(message) + class TransactionAlreadyConfirmed( + ) : NodeException() { + override val message + get() = "" + } - class AsyncPaymentServicesDisabled(message: String) : NodeException(message) + class NoSpendableOutputs( + ) : NodeException() { + override val message + get() = "" + } - class CannotRbfFundingTransaction(message: String) : NodeException(message) + class CoinSelectionFailed( + ) : NodeException() { + override val message + get() = "" + } - class TransactionNotFound(message: String) : NodeException(message) + class InvalidMnemonic( + ) : NodeException() { + override val message + get() = "" + } - class TransactionAlreadyConfirmed(message: String) : NodeException(message) + class BackgroundSyncNotEnabled( + ) : NodeException() { + override val message + get() = "" + } - class NoSpendableOutputs(message: String) : NodeException(message) + class AddressTypeAlreadyMonitored( + ) : NodeException() { + override val message + get() = "" + } - class CoinSelectionFailed(message: String) : NodeException(message) + class AddressTypeIsPrimary( + ) : NodeException() { + override val message + get() = "" + } - class InvalidMnemonic(message: String) : NodeException(message) + class AddressTypeNotMonitored( + ) : NodeException() { + override val message + get() = "" + } - class BackgroundSyncNotEnabled(message: String) : NodeException(message) + class OnchainWalletAccountNotRegistered( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeAlreadyMonitored(message: String) : NodeException(message) + class InvalidSeedBytes( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeIsPrimary(message: String) : NodeException(message) + class OnchainTxBroadcastRejected( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class AddressTypeNotMonitored(message: String) : NodeException(message) + class OnchainTxBroadcastFailed( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class OnchainWalletAccountNotRegistered(message: String) : NodeException(message) + class OnchainTxBroadcastTimeout( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class InvalidSeedBytes(message: String) : NodeException(message) + class OnchainTxBroadcastNotDispatched( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } } @@ -2411,6 +2730,8 @@ enum class WordCount { + + diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt index 945f76a53c..cfc0b1558c 100644 --- a/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt +++ b/bindings/kotlin/ldk-node-jvm/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.jvm.kt @@ -1545,6 +1545,12 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + + + @@ -2608,6 +2614,11 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): Unit + fun uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit fun uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp( `ptr`: Pointer?, `txid`: RustBufferByValue, @@ -2674,6 +2685,10 @@ internal interface UniffiLib : Library { `utxosToSpend`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): Long + fun uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( + `ptr`: Pointer?, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs( `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, @@ -2708,6 +2723,11 @@ internal interface UniffiLib : Library { `addressType`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to( `ptr`: Pointer?, `addressType`: RustBufferByValue, @@ -3464,6 +3484,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_offer_supports_chain( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index( @@ -3482,6 +3504,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address( @@ -3496,6 +3520,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to_account( @@ -4091,6 +4117,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_offer_supports_chain() != 2135.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4118,6 +4147,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts() != 40346.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4139,6 +4171,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type() != 62171.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction() != 36642.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to() != 44189.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8326,6 +8361,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { } + @Throws(NodeException::class) + override fun `abandonPendingBroadcast`(`txid`: Txid) { + callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + } + } + @Throws(NodeException::class) override fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -8464,6 +8512,18 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `listPendingBroadcasts`(): List { + return FfiConverterSequenceTypePendingBroadcastInfo.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( + it, + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `listSpendableOutputs`(): List { return FfiConverterSequenceTypeSpendableUtxo.lift(callWithPointer { @@ -8554,6 +8614,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `rebroadcastTransaction`(`txid`: Txid): Txid { + return FfiConverterTypeTxid.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) { callWithPointer { @@ -10490,6 +10563,28 @@ object FfiConverterTypePeerDetails: FfiConverterRustBuffer { +object FfiConverterTypePendingBroadcastInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PendingBroadcastInfo { + return PendingBroadcastInfo( + FfiConverterTypeTxid.read(buf), + FfiConverterSequenceTypeTxid.read(buf), + ) + } + + override fun allocationSize(value: PendingBroadcastInfo) = ( + FfiConverterTypeTxid.allocationSize(value.`txid`) + + FfiConverterSequenceTypeTxid.allocationSize(value.`lineage`) + ) + + override fun write(value: PendingBroadcastInfo, buf: ByteBuffer) { + FfiConverterTypeTxid.write(value.`txid`, buf) + FfiConverterSequenceTypeTxid.write(value.`lineage`, buf) + } +} + + + + object FfiConverterTypeProbeHandle: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProbeHandle { return ProbeHandle( @@ -12121,80 +12216,385 @@ object NodeExceptionErrorHandler : UniffiRustCallStatusErrorHandler { override fun read(buf: ByteBuffer): NodeException { return when (buf.getInt()) { - 1 -> NodeException.AlreadyRunning(FfiConverterString.read(buf)) - 2 -> NodeException.NotRunning(FfiConverterString.read(buf)) - 3 -> NodeException.OnchainTxCreationFailed(FfiConverterString.read(buf)) - 4 -> NodeException.ConnectionFailed(FfiConverterString.read(buf)) - 5 -> NodeException.InvoiceCreationFailed(FfiConverterString.read(buf)) - 6 -> NodeException.InvoiceRequestCreationFailed(FfiConverterString.read(buf)) - 7 -> NodeException.OfferCreationFailed(FfiConverterString.read(buf)) - 8 -> NodeException.RefundCreationFailed(FfiConverterString.read(buf)) - 9 -> NodeException.PaymentSendingFailed(FfiConverterString.read(buf)) - 10 -> NodeException.InvalidCustomTlvs(FfiConverterString.read(buf)) - 11 -> NodeException.ProbeSendingFailed(FfiConverterString.read(buf)) - 12 -> NodeException.RouteNotFound(FfiConverterString.read(buf)) - 13 -> NodeException.ChannelCreationFailed(FfiConverterString.read(buf)) - 14 -> NodeException.ChannelClosingFailed(FfiConverterString.read(buf)) - 15 -> NodeException.ChannelSplicingFailed(FfiConverterString.read(buf)) - 16 -> NodeException.ChannelConfigUpdateFailed(FfiConverterString.read(buf)) - 17 -> NodeException.PersistenceFailed(FfiConverterString.read(buf)) - 18 -> NodeException.FeerateEstimationUpdateFailed(FfiConverterString.read(buf)) - 19 -> NodeException.FeerateEstimationUpdateTimeout(FfiConverterString.read(buf)) - 20 -> NodeException.WalletOperationFailed(FfiConverterString.read(buf)) - 21 -> NodeException.WalletOperationTimeout(FfiConverterString.read(buf)) - 22 -> NodeException.OnchainTxSigningFailed(FfiConverterString.read(buf)) - 23 -> NodeException.TxSyncFailed(FfiConverterString.read(buf)) - 24 -> NodeException.TxSyncTimeout(FfiConverterString.read(buf)) - 25 -> NodeException.GossipUpdateFailed(FfiConverterString.read(buf)) - 26 -> NodeException.GossipUpdateTimeout(FfiConverterString.read(buf)) - 27 -> NodeException.LiquidityRequestFailed(FfiConverterString.read(buf)) - 28 -> NodeException.UriParameterParsingFailed(FfiConverterString.read(buf)) - 29 -> NodeException.InvalidAddress(FfiConverterString.read(buf)) - 30 -> NodeException.InvalidSocketAddress(FfiConverterString.read(buf)) - 31 -> NodeException.InvalidPublicKey(FfiConverterString.read(buf)) - 32 -> NodeException.InvalidSecretKey(FfiConverterString.read(buf)) - 33 -> NodeException.InvalidOfferId(FfiConverterString.read(buf)) - 34 -> NodeException.InvalidNodeId(FfiConverterString.read(buf)) - 35 -> NodeException.InvalidPaymentId(FfiConverterString.read(buf)) - 36 -> NodeException.InvalidPaymentHash(FfiConverterString.read(buf)) - 37 -> NodeException.InvalidPaymentPreimage(FfiConverterString.read(buf)) - 38 -> NodeException.InvalidPaymentSecret(FfiConverterString.read(buf)) - 39 -> NodeException.InvalidAmount(FfiConverterString.read(buf)) - 40 -> NodeException.InvalidInvoice(FfiConverterString.read(buf)) - 41 -> NodeException.InvalidOffer(FfiConverterString.read(buf)) - 42 -> NodeException.InvalidRefund(FfiConverterString.read(buf)) - 43 -> NodeException.InvalidChannelId(FfiConverterString.read(buf)) - 44 -> NodeException.InvalidNetwork(FfiConverterString.read(buf)) - 45 -> NodeException.InvalidUri(FfiConverterString.read(buf)) - 46 -> NodeException.InvalidQuantity(FfiConverterString.read(buf)) - 47 -> NodeException.InvalidNodeAlias(FfiConverterString.read(buf)) - 48 -> NodeException.InvalidDateTime(FfiConverterString.read(buf)) - 49 -> NodeException.InvalidFeeRate(FfiConverterString.read(buf)) - 50 -> NodeException.DuplicatePayment(FfiConverterString.read(buf)) - 51 -> NodeException.UnsupportedCurrency(FfiConverterString.read(buf)) - 52 -> NodeException.InsufficientFunds(FfiConverterString.read(buf)) - 53 -> NodeException.LiquiditySourceUnavailable(FfiConverterString.read(buf)) - 54 -> NodeException.LiquidityFeeTooHigh(FfiConverterString.read(buf)) - 55 -> NodeException.InvalidBlindedPaths(FfiConverterString.read(buf)) - 56 -> NodeException.AsyncPaymentServicesDisabled(FfiConverterString.read(buf)) - 57 -> NodeException.CannotRbfFundingTransaction(FfiConverterString.read(buf)) - 58 -> NodeException.TransactionNotFound(FfiConverterString.read(buf)) - 59 -> NodeException.TransactionAlreadyConfirmed(FfiConverterString.read(buf)) - 60 -> NodeException.NoSpendableOutputs(FfiConverterString.read(buf)) - 61 -> NodeException.CoinSelectionFailed(FfiConverterString.read(buf)) - 62 -> NodeException.InvalidMnemonic(FfiConverterString.read(buf)) - 63 -> NodeException.BackgroundSyncNotEnabled(FfiConverterString.read(buf)) - 64 -> NodeException.AddressTypeAlreadyMonitored(FfiConverterString.read(buf)) - 65 -> NodeException.AddressTypeIsPrimary(FfiConverterString.read(buf)) - 66 -> NodeException.AddressTypeNotMonitored(FfiConverterString.read(buf)) - 67 -> NodeException.OnchainWalletAccountNotRegistered(FfiConverterString.read(buf)) - 68 -> NodeException.InvalidSeedBytes(FfiConverterString.read(buf)) + 1 -> NodeException.AlreadyRunning() + 2 -> NodeException.NotRunning() + 3 -> NodeException.OnchainTxCreationFailed() + 4 -> NodeException.ConnectionFailed() + 5 -> NodeException.InvoiceCreationFailed() + 6 -> NodeException.InvoiceRequestCreationFailed() + 7 -> NodeException.OfferCreationFailed() + 8 -> NodeException.RefundCreationFailed() + 9 -> NodeException.PaymentSendingFailed() + 10 -> NodeException.InvalidCustomTlvs() + 11 -> NodeException.ProbeSendingFailed() + 12 -> NodeException.RouteNotFound() + 13 -> NodeException.ChannelCreationFailed() + 14 -> NodeException.ChannelClosingFailed() + 15 -> NodeException.ChannelSplicingFailed() + 16 -> NodeException.ChannelConfigUpdateFailed() + 17 -> NodeException.PersistenceFailed() + 18 -> NodeException.FeerateEstimationUpdateFailed() + 19 -> NodeException.FeerateEstimationUpdateTimeout() + 20 -> NodeException.WalletOperationFailed() + 21 -> NodeException.WalletOperationTimeout() + 22 -> NodeException.OnchainTxSigningFailed() + 23 -> NodeException.TxSyncFailed() + 24 -> NodeException.TxSyncTimeout() + 25 -> NodeException.GossipUpdateFailed() + 26 -> NodeException.GossipUpdateTimeout() + 27 -> NodeException.LiquidityRequestFailed() + 28 -> NodeException.UriParameterParsingFailed() + 29 -> NodeException.InvalidAddress() + 30 -> NodeException.InvalidSocketAddress() + 31 -> NodeException.InvalidPublicKey() + 32 -> NodeException.InvalidSecretKey() + 33 -> NodeException.InvalidOfferId() + 34 -> NodeException.InvalidNodeId() + 35 -> NodeException.InvalidPaymentId() + 36 -> NodeException.InvalidPaymentHash() + 37 -> NodeException.InvalidPaymentPreimage() + 38 -> NodeException.InvalidPaymentSecret() + 39 -> NodeException.InvalidAmount() + 40 -> NodeException.InvalidInvoice() + 41 -> NodeException.InvalidOffer() + 42 -> NodeException.InvalidRefund() + 43 -> NodeException.InvalidChannelId() + 44 -> NodeException.InvalidNetwork() + 45 -> NodeException.InvalidUri() + 46 -> NodeException.InvalidQuantity() + 47 -> NodeException.InvalidNodeAlias() + 48 -> NodeException.InvalidDateTime() + 49 -> NodeException.InvalidFeeRate() + 50 -> NodeException.DuplicatePayment() + 51 -> NodeException.UnsupportedCurrency() + 52 -> NodeException.InsufficientFunds() + 53 -> NodeException.LiquiditySourceUnavailable() + 54 -> NodeException.LiquidityFeeTooHigh() + 55 -> NodeException.InvalidBlindedPaths() + 56 -> NodeException.AsyncPaymentServicesDisabled() + 57 -> NodeException.CannotRbfFundingTransaction() + 58 -> NodeException.TransactionNotFound() + 59 -> NodeException.TransactionAlreadyConfirmed() + 60 -> NodeException.NoSpendableOutputs() + 61 -> NodeException.CoinSelectionFailed() + 62 -> NodeException.InvalidMnemonic() + 63 -> NodeException.BackgroundSyncNotEnabled() + 64 -> NodeException.AddressTypeAlreadyMonitored() + 65 -> NodeException.AddressTypeIsPrimary() + 66 -> NodeException.AddressTypeNotMonitored() + 67 -> NodeException.OnchainWalletAccountNotRegistered() + 68 -> NodeException.InvalidSeedBytes() + 69 -> NodeException.OnchainTxBroadcastRejected( + FfiConverterTypeTxid.read(buf), + ) + 70 -> NodeException.OnchainTxBroadcastFailed( + FfiConverterTypeTxid.read(buf), + ) + 71 -> NodeException.OnchainTxBroadcastTimeout( + FfiConverterTypeTxid.read(buf), + ) + 72 -> NodeException.OnchainTxBroadcastNotDispatched( + FfiConverterTypeTxid.read(buf), + ) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } override fun allocationSize(value: NodeException): ULong { - return 4UL + return when (value) { + is NodeException.AlreadyRunning -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.NotRunning -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainTxCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ConnectionFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvoiceCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvoiceRequestCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OfferCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.RefundCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.PaymentSendingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidCustomTlvs -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ProbeSendingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.RouteNotFound -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelCreationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelClosingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelSplicingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.ChannelConfigUpdateFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.PersistenceFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.FeerateEstimationUpdateFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.FeerateEstimationUpdateTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.WalletOperationFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.WalletOperationTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainTxSigningFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TxSyncFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TxSyncTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.GossipUpdateFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.GossipUpdateTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.LiquidityRequestFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.UriParameterParsingFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidAddress -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidSocketAddress -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPublicKey -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidSecretKey -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidOfferId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidNodeId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentHash -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentPreimage -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidPaymentSecret -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidAmount -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidInvoice -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidOffer -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidRefund -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidChannelId -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidNetwork -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidUri -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidQuantity -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidNodeAlias -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidDateTime -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidFeeRate -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.DuplicatePayment -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.UnsupportedCurrency -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InsufficientFunds -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.LiquiditySourceUnavailable -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.LiquidityFeeTooHigh -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidBlindedPaths -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AsyncPaymentServicesDisabled -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.CannotRbfFundingTransaction -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TransactionNotFound -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.TransactionAlreadyConfirmed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.NoSpendableOutputs -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.CoinSelectionFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidMnemonic -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.BackgroundSyncNotEnabled -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AddressTypeAlreadyMonitored -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AddressTypeIsPrimary -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.AddressTypeNotMonitored -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainWalletAccountNotRegistered -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.InvalidSeedBytes -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + ) + is NodeException.OnchainTxBroadcastRejected -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + is NodeException.OnchainTxBroadcastFailed -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + is NodeException.OnchainTxBroadcastTimeout -> ( + // Add the size for the Int that specifies the variant plus the size needed for all fields + 4UL + + FfiConverterTypeTxid.allocationSize(value.`txid`) + ) + is NodeException.OnchainTxBroadcastNotDispatched -> ( + // 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: NodeException, buf: ByteBuffer) { @@ -12471,6 +12871,26 @@ object FfiConverterTypeNodeError : FfiConverterRustBuffer { buf.putInt(68) Unit } + is NodeException.OnchainTxBroadcastRejected -> { + buf.putInt(69) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is NodeException.OnchainTxBroadcastFailed -> { + buf.putInt(70) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is NodeException.OnchainTxBroadcastTimeout -> { + buf.putInt(71) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } + is NodeException.OnchainTxBroadcastNotDispatched -> { + buf.putInt(72) + FfiConverterTypeTxid.write(value.`txid`, buf) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } @@ -14576,6 +14996,31 @@ object FfiConverterSequenceTypePeerDetails: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypePendingBroadcastInfo.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.sumOf { FfiConverterTypePendingBroadcastInfo.allocationSize(it) } + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypePendingBroadcastInfo.write(it, buf) + } + } +} + + + + object FfiConverterSequenceTypeProbeHandle: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib index dfdaeae63d..5c104a7d19 100644 Binary files a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib and b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-aarch64/libldk_node.dylib differ diff --git a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib index d91a777c25..2a90cab6e8 100644 Binary files a/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib and b/bindings/kotlin/ldk-node-jvm/lib/src/main/resources/darwin-x86-64/libldk_node.dylib differ diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 9cdcdc85b4..62d8a205f9 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -387,6 +387,12 @@ interface OnchainPayment { [Throws=NodeError] Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); [Throws=NodeError] + Txid rebroadcast_transaction([ByRef]Txid txid); + [Throws=NodeError] + sequence list_pending_broadcasts(); + [Throws=NodeError] + void abandon_pending_broadcast([ByRef]Txid txid); + [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); @@ -405,6 +411,11 @@ enum CoinSelectionAlgorithm { "SingleRandomDraw", }; +dictionary PendingBroadcastInfo { + Txid txid; + sequence lineage; +}; + dictionary SpendableUtxo { OutPoint outpoint; u64 value_sats; @@ -435,75 +446,79 @@ interface LSPS1Liquidity { }; [Error] -enum NodeError { - "AlreadyRunning", - "NotRunning", - "OnchainTxCreationFailed", - "ConnectionFailed", - "InvoiceCreationFailed", - "InvoiceRequestCreationFailed", - "OfferCreationFailed", - "RefundCreationFailed", - "PaymentSendingFailed", - "InvalidCustomTlvs", - "ProbeSendingFailed", - "RouteNotFound", - "ChannelCreationFailed", - "ChannelClosingFailed", - "ChannelSplicingFailed", - "ChannelConfigUpdateFailed", - "PersistenceFailed", - "FeerateEstimationUpdateFailed", - "FeerateEstimationUpdateTimeout", - "WalletOperationFailed", - "WalletOperationTimeout", - "OnchainTxSigningFailed", - "TxSyncFailed", - "TxSyncTimeout", - "GossipUpdateFailed", - "GossipUpdateTimeout", - "LiquidityRequestFailed", - "UriParameterParsingFailed", - "InvalidAddress", - "InvalidSocketAddress", - "InvalidPublicKey", - "InvalidSecretKey", - "InvalidOfferId", - "InvalidNodeId", - "InvalidPaymentId", - "InvalidPaymentHash", - "InvalidPaymentPreimage", - "InvalidPaymentSecret", - "InvalidAmount", - "InvalidInvoice", - "InvalidOffer", - "InvalidRefund", - "InvalidChannelId", - "InvalidNetwork", - "InvalidUri", - "InvalidQuantity", - "InvalidNodeAlias", - "InvalidDateTime", - "InvalidFeeRate", - "DuplicatePayment", - "UnsupportedCurrency", - "InsufficientFunds", - "LiquiditySourceUnavailable", - "LiquidityFeeTooHigh", - "InvalidBlindedPaths", - "AsyncPaymentServicesDisabled", - "CannotRbfFundingTransaction", - "TransactionNotFound", - "TransactionAlreadyConfirmed", - "NoSpendableOutputs", - "CoinSelectionFailed", - "InvalidMnemonic", - "BackgroundSyncNotEnabled", - "AddressTypeAlreadyMonitored", - "AddressTypeIsPrimary", - "AddressTypeNotMonitored", - "OnchainWalletAccountNotRegistered", - "InvalidSeedBytes", +interface NodeError { + AlreadyRunning(); + NotRunning(); + OnchainTxCreationFailed(); + ConnectionFailed(); + InvoiceCreationFailed(); + InvoiceRequestCreationFailed(); + OfferCreationFailed(); + RefundCreationFailed(); + PaymentSendingFailed(); + InvalidCustomTlvs(); + ProbeSendingFailed(); + RouteNotFound(); + ChannelCreationFailed(); + ChannelClosingFailed(); + ChannelSplicingFailed(); + ChannelConfigUpdateFailed(); + PersistenceFailed(); + FeerateEstimationUpdateFailed(); + FeerateEstimationUpdateTimeout(); + WalletOperationFailed(); + WalletOperationTimeout(); + OnchainTxSigningFailed(); + TxSyncFailed(); + TxSyncTimeout(); + GossipUpdateFailed(); + GossipUpdateTimeout(); + LiquidityRequestFailed(); + UriParameterParsingFailed(); + InvalidAddress(); + InvalidSocketAddress(); + InvalidPublicKey(); + InvalidSecretKey(); + InvalidOfferId(); + InvalidNodeId(); + InvalidPaymentId(); + InvalidPaymentHash(); + InvalidPaymentPreimage(); + InvalidPaymentSecret(); + InvalidAmount(); + InvalidInvoice(); + InvalidOffer(); + InvalidRefund(); + InvalidChannelId(); + InvalidNetwork(); + InvalidUri(); + InvalidQuantity(); + InvalidNodeAlias(); + InvalidDateTime(); + InvalidFeeRate(); + DuplicatePayment(); + UnsupportedCurrency(); + InsufficientFunds(); + LiquiditySourceUnavailable(); + LiquidityFeeTooHigh(); + InvalidBlindedPaths(); + AsyncPaymentServicesDisabled(); + CannotRbfFundingTransaction(); + TransactionNotFound(); + TransactionAlreadyConfirmed(); + NoSpendableOutputs(); + CoinSelectionFailed(); + InvalidMnemonic(); + BackgroundSyncNotEnabled(); + AddressTypeAlreadyMonitored(); + AddressTypeIsPrimary(); + AddressTypeNotMonitored(); + OnchainWalletAccountNotRegistered(); + InvalidSeedBytes(); + OnchainTxBroadcastRejected(Txid txid); + OnchainTxBroadcastFailed(Txid txid); + OnchainTxBroadcastTimeout(Txid txid); + OnchainTxBroadcastNotDispatched(Txid txid); }; dictionary NodeStatus { 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..01dc547b43 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -805,6 +805,8 @@ 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_offer_supports_chain() != 2135: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index() != 63246: @@ -823,6 +825,8 @@ 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_calculate_total_fee() != 57218: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts() != 40346: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address() != 37251: @@ -837,6 +841,8 @@ 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_new_address_info_for_type() != 62171: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction() != 36642: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to() != 44189: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to_account() != 53588: @@ -2249,6 +2255,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_free_onchainpayment.restype = None +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast.restype = None _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -2324,6 +2336,11 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_total_fee.restype = ctypes.c_uint64 +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -2365,6 +2382,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_new_address_info_for_type.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -3389,6 +3412,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_offer_supports_chain.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_offer_supports_chain.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp.restype = ctypes.c_uint16 @@ -3416,6 +3442,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs.restype = ctypes.c_uint16 @@ -3437,6 +3466,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to.restype = ctypes.c_uint16 @@ -6885,6 +6917,8 @@ def write(cls, value: OfferProtocol, buf: _UniffiRustBuffer): class OnchainPaymentProtocol(typing.Protocol): + def abandon_pending_broadcast(self, txid: "Txid"): + raise NotImplementedError def accelerate_by_cpfp(self, txid: "Txid",fee_rate: "typing.Optional[FeeRate]",destination_address: "typing.Optional[Address]"): raise NotImplementedError def address_info_for_account_at_index(self, address_type: "AddressType",account_index: "int",keychain: "KeychainKind",index: "int"): @@ -6903,6 +6937,8 @@ def calculate_send_all_fee(self, address: "Address",retain_reserves: "bool",fee_ raise NotImplementedError def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]"): raise NotImplementedError + def list_pending_broadcasts(self, ): + raise NotImplementedError def list_spendable_outputs(self, ): raise NotImplementedError def new_address(self, ): @@ -6917,6 +6953,8 @@ def new_address_info_for_account(self, address_type: "AddressType",account_index raise NotImplementedError def new_address_info_for_type(self, address_type: "AddressType"): raise NotImplementedError + def rebroadcast_transaction(self, txid: "Txid"): + raise NotImplementedError def reveal_receive_addresses_to(self, address_type: "AddressType",index: "int"): raise NotImplementedError def reveal_receive_addresses_to_account(self, address_type: "AddressType",account_index: "int",index: "int"): @@ -6954,6 +6992,17 @@ def _make_instance_(cls, pointer): return inst + def abandon_pending_broadcast(self, txid: "Txid") -> None: + _UniffiConverterTypeTxid.check_lower(txid) + + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast,self._uniffi_clone_pointer(), + _UniffiConverterTypeTxid.lower(txid)) + + + + + + def accelerate_by_cpfp(self, txid: "Txid",fee_rate: "typing.Optional[FeeRate]",destination_address: "typing.Optional[Address]") -> "Txid": _UniffiConverterTypeTxid.check_lower(txid) @@ -7125,6 +7174,15 @@ def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "t + def list_pending_broadcasts(self, ) -> "typing.List[PendingBroadcastInfo]": + return _UniffiConverterSequenceTypePendingBroadcastInfo.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts,self._uniffi_clone_pointer(),) + ) + + + + + def list_spendable_outputs(self, ) -> "typing.List[SpendableUtxo]": return _UniffiConverterSequenceTypeSpendableUtxo.lift( _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs,self._uniffi_clone_pointer(),) @@ -7206,6 +7264,18 @@ def new_address_info_for_type(self, address_type: "AddressType") -> "AddressInfo + def rebroadcast_transaction(self, txid: "Txid") -> "Txid": + _UniffiConverterTypeTxid.check_lower(txid) + + return _UniffiConverterTypeTxid.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction,self._uniffi_clone_pointer(), + _UniffiConverterTypeTxid.lower(txid)) + ) + + + + + def reveal_receive_addresses_to(self, address_type: "AddressType",index: "int") -> None: _UniffiConverterTypeAddressType.check_lower(address_type) @@ -9762,6 +9832,42 @@ def write(value, buf): _UniffiConverterBool.write(value.is_connected, buf) +class PendingBroadcastInfo: + txid: "Txid" + lineage: "typing.List[Txid]" + def __init__(self, *, txid: "Txid", lineage: "typing.List[Txid]"): + self.txid = txid + self.lineage = lineage + + def __str__(self): + return "PendingBroadcastInfo(txid={}, lineage={})".format(self.txid, self.lineage) + + def __eq__(self, other): + if self.txid != other.txid: + return False + if self.lineage != other.lineage: + return False + return True + +class _UniffiConverterTypePendingBroadcastInfo(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return PendingBroadcastInfo( + txid=_UniffiConverterTypeTxid.read(buf), + lineage=_UniffiConverterSequenceTypeTxid.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterTypeTxid.check_lower(value.txid) + _UniffiConverterSequenceTypeTxid.check_lower(value.lineage) + + @staticmethod + def write(value, buf): + _UniffiConverterTypeTxid.write(value.txid, buf) + _UniffiConverterSequenceTypeTxid.write(value.lineage, buf) + + class ProbeHandle: payment_hash: "PaymentHash" payment_id: "PaymentId" @@ -13181,345 +13287,521 @@ class NodeError(Exception): class NodeError: # type: ignore class AlreadyRunning(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AlreadyRunning({})".format(repr(str(self))) + return "NodeError.AlreadyRunning({})".format(str(self)) _UniffiTempNodeError.AlreadyRunning = AlreadyRunning # type: ignore class NotRunning(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.NotRunning({})".format(repr(str(self))) + return "NodeError.NotRunning({})".format(str(self)) _UniffiTempNodeError.NotRunning = NotRunning # type: ignore class OnchainTxCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OnchainTxCreationFailed({})".format(repr(str(self))) + return "NodeError.OnchainTxCreationFailed({})".format(str(self)) _UniffiTempNodeError.OnchainTxCreationFailed = OnchainTxCreationFailed # type: ignore class ConnectionFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ConnectionFailed({})".format(repr(str(self))) + return "NodeError.ConnectionFailed({})".format(str(self)) _UniffiTempNodeError.ConnectionFailed = ConnectionFailed # type: ignore class InvoiceCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvoiceCreationFailed({})".format(repr(str(self))) + return "NodeError.InvoiceCreationFailed({})".format(str(self)) _UniffiTempNodeError.InvoiceCreationFailed = InvoiceCreationFailed # type: ignore class InvoiceRequestCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvoiceRequestCreationFailed({})".format(repr(str(self))) + return "NodeError.InvoiceRequestCreationFailed({})".format(str(self)) _UniffiTempNodeError.InvoiceRequestCreationFailed = InvoiceRequestCreationFailed # type: ignore class OfferCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OfferCreationFailed({})".format(repr(str(self))) + return "NodeError.OfferCreationFailed({})".format(str(self)) _UniffiTempNodeError.OfferCreationFailed = OfferCreationFailed # type: ignore class RefundCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.RefundCreationFailed({})".format(repr(str(self))) + return "NodeError.RefundCreationFailed({})".format(str(self)) _UniffiTempNodeError.RefundCreationFailed = RefundCreationFailed # type: ignore class PaymentSendingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.PaymentSendingFailed({})".format(repr(str(self))) + return "NodeError.PaymentSendingFailed({})".format(str(self)) _UniffiTempNodeError.PaymentSendingFailed = PaymentSendingFailed # type: ignore class InvalidCustomTlvs(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidCustomTlvs({})".format(repr(str(self))) + return "NodeError.InvalidCustomTlvs({})".format(str(self)) _UniffiTempNodeError.InvalidCustomTlvs = InvalidCustomTlvs # type: ignore class ProbeSendingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ProbeSendingFailed({})".format(repr(str(self))) + return "NodeError.ProbeSendingFailed({})".format(str(self)) _UniffiTempNodeError.ProbeSendingFailed = ProbeSendingFailed # type: ignore class RouteNotFound(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.RouteNotFound({})".format(repr(str(self))) + return "NodeError.RouteNotFound({})".format(str(self)) _UniffiTempNodeError.RouteNotFound = RouteNotFound # type: ignore class ChannelCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelCreationFailed({})".format(repr(str(self))) + return "NodeError.ChannelCreationFailed({})".format(str(self)) _UniffiTempNodeError.ChannelCreationFailed = ChannelCreationFailed # type: ignore class ChannelClosingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelClosingFailed({})".format(repr(str(self))) + return "NodeError.ChannelClosingFailed({})".format(str(self)) _UniffiTempNodeError.ChannelClosingFailed = ChannelClosingFailed # type: ignore class ChannelSplicingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelSplicingFailed({})".format(repr(str(self))) + return "NodeError.ChannelSplicingFailed({})".format(str(self)) _UniffiTempNodeError.ChannelSplicingFailed = ChannelSplicingFailed # type: ignore class ChannelConfigUpdateFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelConfigUpdateFailed({})".format(repr(str(self))) + return "NodeError.ChannelConfigUpdateFailed({})".format(str(self)) _UniffiTempNodeError.ChannelConfigUpdateFailed = ChannelConfigUpdateFailed # type: ignore class PersistenceFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.PersistenceFailed({})".format(repr(str(self))) + return "NodeError.PersistenceFailed({})".format(str(self)) _UniffiTempNodeError.PersistenceFailed = PersistenceFailed # type: ignore class FeerateEstimationUpdateFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.FeerateEstimationUpdateFailed({})".format(repr(str(self))) + return "NodeError.FeerateEstimationUpdateFailed({})".format(str(self)) _UniffiTempNodeError.FeerateEstimationUpdateFailed = FeerateEstimationUpdateFailed # type: ignore class FeerateEstimationUpdateTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.FeerateEstimationUpdateTimeout({})".format(repr(str(self))) + return "NodeError.FeerateEstimationUpdateTimeout({})".format(str(self)) _UniffiTempNodeError.FeerateEstimationUpdateTimeout = FeerateEstimationUpdateTimeout # type: ignore class WalletOperationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.WalletOperationFailed({})".format(repr(str(self))) + return "NodeError.WalletOperationFailed({})".format(str(self)) _UniffiTempNodeError.WalletOperationFailed = WalletOperationFailed # type: ignore class WalletOperationTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.WalletOperationTimeout({})".format(repr(str(self))) + return "NodeError.WalletOperationTimeout({})".format(str(self)) _UniffiTempNodeError.WalletOperationTimeout = WalletOperationTimeout # type: ignore class OnchainTxSigningFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OnchainTxSigningFailed({})".format(repr(str(self))) + return "NodeError.OnchainTxSigningFailed({})".format(str(self)) _UniffiTempNodeError.OnchainTxSigningFailed = OnchainTxSigningFailed # type: ignore class TxSyncFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TxSyncFailed({})".format(repr(str(self))) + return "NodeError.TxSyncFailed({})".format(str(self)) _UniffiTempNodeError.TxSyncFailed = TxSyncFailed # type: ignore class TxSyncTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TxSyncTimeout({})".format(repr(str(self))) + return "NodeError.TxSyncTimeout({})".format(str(self)) _UniffiTempNodeError.TxSyncTimeout = TxSyncTimeout # type: ignore class GossipUpdateFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.GossipUpdateFailed({})".format(repr(str(self))) + return "NodeError.GossipUpdateFailed({})".format(str(self)) _UniffiTempNodeError.GossipUpdateFailed = GossipUpdateFailed # type: ignore class GossipUpdateTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.GossipUpdateTimeout({})".format(repr(str(self))) + return "NodeError.GossipUpdateTimeout({})".format(str(self)) _UniffiTempNodeError.GossipUpdateTimeout = GossipUpdateTimeout # type: ignore class LiquidityRequestFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.LiquidityRequestFailed({})".format(repr(str(self))) + return "NodeError.LiquidityRequestFailed({})".format(str(self)) _UniffiTempNodeError.LiquidityRequestFailed = LiquidityRequestFailed # type: ignore class UriParameterParsingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.UriParameterParsingFailed({})".format(repr(str(self))) + return "NodeError.UriParameterParsingFailed({})".format(str(self)) _UniffiTempNodeError.UriParameterParsingFailed = UriParameterParsingFailed # type: ignore class InvalidAddress(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidAddress({})".format(repr(str(self))) + return "NodeError.InvalidAddress({})".format(str(self)) _UniffiTempNodeError.InvalidAddress = InvalidAddress # type: ignore class InvalidSocketAddress(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidSocketAddress({})".format(repr(str(self))) + return "NodeError.InvalidSocketAddress({})".format(str(self)) _UniffiTempNodeError.InvalidSocketAddress = InvalidSocketAddress # type: ignore class InvalidPublicKey(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPublicKey({})".format(repr(str(self))) + return "NodeError.InvalidPublicKey({})".format(str(self)) _UniffiTempNodeError.InvalidPublicKey = InvalidPublicKey # type: ignore class InvalidSecretKey(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidSecretKey({})".format(repr(str(self))) + return "NodeError.InvalidSecretKey({})".format(str(self)) _UniffiTempNodeError.InvalidSecretKey = InvalidSecretKey # type: ignore class InvalidOfferId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidOfferId({})".format(repr(str(self))) + return "NodeError.InvalidOfferId({})".format(str(self)) _UniffiTempNodeError.InvalidOfferId = InvalidOfferId # type: ignore class InvalidNodeId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidNodeId({})".format(repr(str(self))) + return "NodeError.InvalidNodeId({})".format(str(self)) _UniffiTempNodeError.InvalidNodeId = InvalidNodeId # type: ignore class InvalidPaymentId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentId({})".format(repr(str(self))) + return "NodeError.InvalidPaymentId({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentId = InvalidPaymentId # type: ignore class InvalidPaymentHash(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentHash({})".format(repr(str(self))) + return "NodeError.InvalidPaymentHash({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentHash = InvalidPaymentHash # type: ignore class InvalidPaymentPreimage(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentPreimage({})".format(repr(str(self))) + return "NodeError.InvalidPaymentPreimage({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentPreimage = InvalidPaymentPreimage # type: ignore class InvalidPaymentSecret(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentSecret({})".format(repr(str(self))) + return "NodeError.InvalidPaymentSecret({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentSecret = InvalidPaymentSecret # type: ignore class InvalidAmount(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidAmount({})".format(repr(str(self))) + return "NodeError.InvalidAmount({})".format(str(self)) _UniffiTempNodeError.InvalidAmount = InvalidAmount # type: ignore class InvalidInvoice(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidInvoice({})".format(repr(str(self))) + return "NodeError.InvalidInvoice({})".format(str(self)) _UniffiTempNodeError.InvalidInvoice = InvalidInvoice # type: ignore class InvalidOffer(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidOffer({})".format(repr(str(self))) + return "NodeError.InvalidOffer({})".format(str(self)) _UniffiTempNodeError.InvalidOffer = InvalidOffer # type: ignore class InvalidRefund(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidRefund({})".format(repr(str(self))) + return "NodeError.InvalidRefund({})".format(str(self)) _UniffiTempNodeError.InvalidRefund = InvalidRefund # type: ignore class InvalidChannelId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidChannelId({})".format(repr(str(self))) + return "NodeError.InvalidChannelId({})".format(str(self)) _UniffiTempNodeError.InvalidChannelId = InvalidChannelId # type: ignore class InvalidNetwork(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidNetwork({})".format(repr(str(self))) + return "NodeError.InvalidNetwork({})".format(str(self)) _UniffiTempNodeError.InvalidNetwork = InvalidNetwork # type: ignore class InvalidUri(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidUri({})".format(repr(str(self))) + return "NodeError.InvalidUri({})".format(str(self)) _UniffiTempNodeError.InvalidUri = InvalidUri # type: ignore class InvalidQuantity(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidQuantity({})".format(repr(str(self))) + return "NodeError.InvalidQuantity({})".format(str(self)) _UniffiTempNodeError.InvalidQuantity = InvalidQuantity # type: ignore class InvalidNodeAlias(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidNodeAlias({})".format(repr(str(self))) + return "NodeError.InvalidNodeAlias({})".format(str(self)) _UniffiTempNodeError.InvalidNodeAlias = InvalidNodeAlias # type: ignore class InvalidDateTime(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidDateTime({})".format(repr(str(self))) + return "NodeError.InvalidDateTime({})".format(str(self)) _UniffiTempNodeError.InvalidDateTime = InvalidDateTime # type: ignore class InvalidFeeRate(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidFeeRate({})".format(repr(str(self))) + return "NodeError.InvalidFeeRate({})".format(str(self)) _UniffiTempNodeError.InvalidFeeRate = InvalidFeeRate # type: ignore class DuplicatePayment(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.DuplicatePayment({})".format(repr(str(self))) + return "NodeError.DuplicatePayment({})".format(str(self)) _UniffiTempNodeError.DuplicatePayment = DuplicatePayment # type: ignore class UnsupportedCurrency(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.UnsupportedCurrency({})".format(repr(str(self))) + return "NodeError.UnsupportedCurrency({})".format(str(self)) _UniffiTempNodeError.UnsupportedCurrency = UnsupportedCurrency # type: ignore class InsufficientFunds(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InsufficientFunds({})".format(repr(str(self))) + return "NodeError.InsufficientFunds({})".format(str(self)) _UniffiTempNodeError.InsufficientFunds = InsufficientFunds # type: ignore class LiquiditySourceUnavailable(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.LiquiditySourceUnavailable({})".format(repr(str(self))) + return "NodeError.LiquiditySourceUnavailable({})".format(str(self)) _UniffiTempNodeError.LiquiditySourceUnavailable = LiquiditySourceUnavailable # type: ignore class LiquidityFeeTooHigh(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.LiquidityFeeTooHigh({})".format(repr(str(self))) + return "NodeError.LiquidityFeeTooHigh({})".format(str(self)) _UniffiTempNodeError.LiquidityFeeTooHigh = LiquidityFeeTooHigh # type: ignore class InvalidBlindedPaths(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidBlindedPaths({})".format(repr(str(self))) + return "NodeError.InvalidBlindedPaths({})".format(str(self)) _UniffiTempNodeError.InvalidBlindedPaths = InvalidBlindedPaths # type: ignore class AsyncPaymentServicesDisabled(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AsyncPaymentServicesDisabled({})".format(repr(str(self))) + return "NodeError.AsyncPaymentServicesDisabled({})".format(str(self)) _UniffiTempNodeError.AsyncPaymentServicesDisabled = AsyncPaymentServicesDisabled # type: ignore class CannotRbfFundingTransaction(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.CannotRbfFundingTransaction({})".format(repr(str(self))) + return "NodeError.CannotRbfFundingTransaction({})".format(str(self)) _UniffiTempNodeError.CannotRbfFundingTransaction = CannotRbfFundingTransaction # type: ignore class TransactionNotFound(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TransactionNotFound({})".format(repr(str(self))) + return "NodeError.TransactionNotFound({})".format(str(self)) _UniffiTempNodeError.TransactionNotFound = TransactionNotFound # type: ignore class TransactionAlreadyConfirmed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TransactionAlreadyConfirmed({})".format(repr(str(self))) + return "NodeError.TransactionAlreadyConfirmed({})".format(str(self)) _UniffiTempNodeError.TransactionAlreadyConfirmed = TransactionAlreadyConfirmed # type: ignore class NoSpendableOutputs(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.NoSpendableOutputs({})".format(repr(str(self))) + return "NodeError.NoSpendableOutputs({})".format(str(self)) _UniffiTempNodeError.NoSpendableOutputs = NoSpendableOutputs # type: ignore class CoinSelectionFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.CoinSelectionFailed({})".format(repr(str(self))) + return "NodeError.CoinSelectionFailed({})".format(str(self)) _UniffiTempNodeError.CoinSelectionFailed = CoinSelectionFailed # type: ignore class InvalidMnemonic(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidMnemonic({})".format(repr(str(self))) + return "NodeError.InvalidMnemonic({})".format(str(self)) _UniffiTempNodeError.InvalidMnemonic = InvalidMnemonic # type: ignore class BackgroundSyncNotEnabled(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.BackgroundSyncNotEnabled({})".format(repr(str(self))) + return "NodeError.BackgroundSyncNotEnabled({})".format(str(self)) _UniffiTempNodeError.BackgroundSyncNotEnabled = BackgroundSyncNotEnabled # type: ignore class AddressTypeAlreadyMonitored(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AddressTypeAlreadyMonitored({})".format(repr(str(self))) + return "NodeError.AddressTypeAlreadyMonitored({})".format(str(self)) _UniffiTempNodeError.AddressTypeAlreadyMonitored = AddressTypeAlreadyMonitored # type: ignore class AddressTypeIsPrimary(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AddressTypeIsPrimary({})".format(repr(str(self))) + return "NodeError.AddressTypeIsPrimary({})".format(str(self)) _UniffiTempNodeError.AddressTypeIsPrimary = AddressTypeIsPrimary # type: ignore class AddressTypeNotMonitored(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AddressTypeNotMonitored({})".format(repr(str(self))) + return "NodeError.AddressTypeNotMonitored({})".format(str(self)) _UniffiTempNodeError.AddressTypeNotMonitored = AddressTypeNotMonitored # type: ignore class OnchainWalletAccountNotRegistered(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OnchainWalletAccountNotRegistered({})".format(repr(str(self))) + return "NodeError.OnchainWalletAccountNotRegistered({})".format(str(self)) _UniffiTempNodeError.OnchainWalletAccountNotRegistered = OnchainWalletAccountNotRegistered # type: ignore class InvalidSeedBytes(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidSeedBytes({})".format(repr(str(self))) + return "NodeError.InvalidSeedBytes({})".format(str(self)) _UniffiTempNodeError.InvalidSeedBytes = InvalidSeedBytes # type: ignore + class OnchainTxBroadcastRejected(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid + + def __repr__(self): + return "NodeError.OnchainTxBroadcastRejected({})".format(str(self)) + _UniffiTempNodeError.OnchainTxBroadcastRejected = OnchainTxBroadcastRejected # type: ignore + class OnchainTxBroadcastFailed(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid + + def __repr__(self): + return "NodeError.OnchainTxBroadcastFailed({})".format(str(self)) + _UniffiTempNodeError.OnchainTxBroadcastFailed = OnchainTxBroadcastFailed # type: ignore + class OnchainTxBroadcastTimeout(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid + + def __repr__(self): + return "NodeError.OnchainTxBroadcastTimeout({})".format(str(self)) + _UniffiTempNodeError.OnchainTxBroadcastTimeout = OnchainTxBroadcastTimeout # type: ignore + class OnchainTxBroadcastNotDispatched(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid + + def __repr__(self): + return "NodeError.OnchainTxBroadcastNotDispatched({})".format(str(self)) + _UniffiTempNodeError.OnchainTxBroadcastNotDispatched = OnchainTxBroadcastNotDispatched # type: ignore NodeError = _UniffiTempNodeError # type: ignore del _UniffiTempNodeError @@ -13531,275 +13813,223 @@ def read(buf): variant = buf.read_i32() if variant == 1: return NodeError.AlreadyRunning( - _UniffiConverterString.read(buf), ) if variant == 2: return NodeError.NotRunning( - _UniffiConverterString.read(buf), ) if variant == 3: return NodeError.OnchainTxCreationFailed( - _UniffiConverterString.read(buf), ) if variant == 4: return NodeError.ConnectionFailed( - _UniffiConverterString.read(buf), ) if variant == 5: return NodeError.InvoiceCreationFailed( - _UniffiConverterString.read(buf), ) if variant == 6: return NodeError.InvoiceRequestCreationFailed( - _UniffiConverterString.read(buf), ) if variant == 7: return NodeError.OfferCreationFailed( - _UniffiConverterString.read(buf), ) if variant == 8: return NodeError.RefundCreationFailed( - _UniffiConverterString.read(buf), ) if variant == 9: return NodeError.PaymentSendingFailed( - _UniffiConverterString.read(buf), ) if variant == 10: return NodeError.InvalidCustomTlvs( - _UniffiConverterString.read(buf), ) if variant == 11: return NodeError.ProbeSendingFailed( - _UniffiConverterString.read(buf), ) if variant == 12: return NodeError.RouteNotFound( - _UniffiConverterString.read(buf), ) if variant == 13: return NodeError.ChannelCreationFailed( - _UniffiConverterString.read(buf), ) if variant == 14: return NodeError.ChannelClosingFailed( - _UniffiConverterString.read(buf), ) if variant == 15: return NodeError.ChannelSplicingFailed( - _UniffiConverterString.read(buf), ) if variant == 16: return NodeError.ChannelConfigUpdateFailed( - _UniffiConverterString.read(buf), ) if variant == 17: return NodeError.PersistenceFailed( - _UniffiConverterString.read(buf), ) if variant == 18: return NodeError.FeerateEstimationUpdateFailed( - _UniffiConverterString.read(buf), ) if variant == 19: return NodeError.FeerateEstimationUpdateTimeout( - _UniffiConverterString.read(buf), ) if variant == 20: return NodeError.WalletOperationFailed( - _UniffiConverterString.read(buf), ) if variant == 21: return NodeError.WalletOperationTimeout( - _UniffiConverterString.read(buf), ) if variant == 22: return NodeError.OnchainTxSigningFailed( - _UniffiConverterString.read(buf), ) if variant == 23: return NodeError.TxSyncFailed( - _UniffiConverterString.read(buf), ) if variant == 24: return NodeError.TxSyncTimeout( - _UniffiConverterString.read(buf), ) if variant == 25: return NodeError.GossipUpdateFailed( - _UniffiConverterString.read(buf), ) if variant == 26: return NodeError.GossipUpdateTimeout( - _UniffiConverterString.read(buf), ) if variant == 27: return NodeError.LiquidityRequestFailed( - _UniffiConverterString.read(buf), ) if variant == 28: return NodeError.UriParameterParsingFailed( - _UniffiConverterString.read(buf), ) if variant == 29: return NodeError.InvalidAddress( - _UniffiConverterString.read(buf), ) if variant == 30: return NodeError.InvalidSocketAddress( - _UniffiConverterString.read(buf), ) if variant == 31: return NodeError.InvalidPublicKey( - _UniffiConverterString.read(buf), ) if variant == 32: return NodeError.InvalidSecretKey( - _UniffiConverterString.read(buf), ) if variant == 33: return NodeError.InvalidOfferId( - _UniffiConverterString.read(buf), ) if variant == 34: return NodeError.InvalidNodeId( - _UniffiConverterString.read(buf), ) if variant == 35: return NodeError.InvalidPaymentId( - _UniffiConverterString.read(buf), ) if variant == 36: return NodeError.InvalidPaymentHash( - _UniffiConverterString.read(buf), ) if variant == 37: return NodeError.InvalidPaymentPreimage( - _UniffiConverterString.read(buf), ) if variant == 38: return NodeError.InvalidPaymentSecret( - _UniffiConverterString.read(buf), ) if variant == 39: return NodeError.InvalidAmount( - _UniffiConverterString.read(buf), ) if variant == 40: return NodeError.InvalidInvoice( - _UniffiConverterString.read(buf), ) if variant == 41: return NodeError.InvalidOffer( - _UniffiConverterString.read(buf), ) if variant == 42: return NodeError.InvalidRefund( - _UniffiConverterString.read(buf), ) if variant == 43: return NodeError.InvalidChannelId( - _UniffiConverterString.read(buf), ) if variant == 44: return NodeError.InvalidNetwork( - _UniffiConverterString.read(buf), ) if variant == 45: return NodeError.InvalidUri( - _UniffiConverterString.read(buf), ) if variant == 46: return NodeError.InvalidQuantity( - _UniffiConverterString.read(buf), ) if variant == 47: return NodeError.InvalidNodeAlias( - _UniffiConverterString.read(buf), ) if variant == 48: return NodeError.InvalidDateTime( - _UniffiConverterString.read(buf), ) if variant == 49: return NodeError.InvalidFeeRate( - _UniffiConverterString.read(buf), ) if variant == 50: return NodeError.DuplicatePayment( - _UniffiConverterString.read(buf), ) if variant == 51: return NodeError.UnsupportedCurrency( - _UniffiConverterString.read(buf), ) if variant == 52: return NodeError.InsufficientFunds( - _UniffiConverterString.read(buf), ) if variant == 53: return NodeError.LiquiditySourceUnavailable( - _UniffiConverterString.read(buf), ) if variant == 54: return NodeError.LiquidityFeeTooHigh( - _UniffiConverterString.read(buf), ) if variant == 55: return NodeError.InvalidBlindedPaths( - _UniffiConverterString.read(buf), ) if variant == 56: return NodeError.AsyncPaymentServicesDisabled( - _UniffiConverterString.read(buf), ) if variant == 57: return NodeError.CannotRbfFundingTransaction( - _UniffiConverterString.read(buf), ) if variant == 58: return NodeError.TransactionNotFound( - _UniffiConverterString.read(buf), ) if variant == 59: return NodeError.TransactionAlreadyConfirmed( - _UniffiConverterString.read(buf), ) if variant == 60: return NodeError.NoSpendableOutputs( - _UniffiConverterString.read(buf), ) if variant == 61: return NodeError.CoinSelectionFailed( - _UniffiConverterString.read(buf), ) if variant == 62: return NodeError.InvalidMnemonic( - _UniffiConverterString.read(buf), ) if variant == 63: return NodeError.BackgroundSyncNotEnabled( - _UniffiConverterString.read(buf), ) if variant == 64: return NodeError.AddressTypeAlreadyMonitored( - _UniffiConverterString.read(buf), ) if variant == 65: return NodeError.AddressTypeIsPrimary( - _UniffiConverterString.read(buf), ) if variant == 66: return NodeError.AddressTypeNotMonitored( - _UniffiConverterString.read(buf), ) if variant == 67: return NodeError.OnchainWalletAccountNotRegistered( - _UniffiConverterString.read(buf), ) if variant == 68: return NodeError.InvalidSeedBytes( - _UniffiConverterString.read(buf), + ) + if variant == 69: + return NodeError.OnchainTxBroadcastRejected( + _UniffiConverterTypeTxid.read(buf), + ) + if variant == 70: + return NodeError.OnchainTxBroadcastFailed( + _UniffiConverterTypeTxid.read(buf), + ) + if variant == 71: + return NodeError.OnchainTxBroadcastTimeout( + _UniffiConverterTypeTxid.read(buf), + ) + if variant == 72: + return NodeError.OnchainTxBroadcastNotDispatched( + _UniffiConverterTypeTxid.read(buf), ) raise InternalError("Raw enum value doesn't match any cases") @@ -13941,6 +14171,18 @@ def check_lower(value): return if isinstance(value, NodeError.InvalidSeedBytes): return + if isinstance(value, NodeError.OnchainTxBroadcastRejected): + _UniffiConverterTypeTxid.check_lower(value.txid) + return + if isinstance(value, NodeError.OnchainTxBroadcastFailed): + _UniffiConverterTypeTxid.check_lower(value.txid) + return + if isinstance(value, NodeError.OnchainTxBroadcastTimeout): + _UniffiConverterTypeTxid.check_lower(value.txid) + return + if isinstance(value, NodeError.OnchainTxBroadcastNotDispatched): + _UniffiConverterTypeTxid.check_lower(value.txid) + return @staticmethod def write(value, buf): @@ -14080,6 +14322,18 @@ def write(value, buf): buf.write_i32(67) if isinstance(value, NodeError.InvalidSeedBytes): buf.write_i32(68) + if isinstance(value, NodeError.OnchainTxBroadcastRejected): + buf.write_i32(69) + _UniffiConverterTypeTxid.write(value.txid, buf) + if isinstance(value, NodeError.OnchainTxBroadcastFailed): + buf.write_i32(70) + _UniffiConverterTypeTxid.write(value.txid, buf) + if isinstance(value, NodeError.OnchainTxBroadcastTimeout): + buf.write_i32(71) + _UniffiConverterTypeTxid.write(value.txid, buf) + if isinstance(value, NodeError.OnchainTxBroadcastNotDispatched): + buf.write_i32(72) + _UniffiConverterTypeTxid.write(value.txid, buf) @@ -16715,6 +16969,31 @@ def read(cls, buf): +class _UniffiConverterSequenceTypePendingBroadcastInfo(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + for item in value: + _UniffiConverterTypePendingBroadcastInfo.check_lower(item) + + @classmethod + def write(cls, value, buf): + items = len(value) + buf.write_i32(items) + for item in value: + _UniffiConverterTypePendingBroadcastInfo.write(item, buf) + + @classmethod + def read(cls, buf): + count = buf.read_i32() + if count < 0: + raise InternalError("Unexpected negative sequence length") + + return [ + _UniffiConverterTypePendingBroadcastInfo.read(buf) for i in range(count) + ] + + + class _UniffiConverterSequenceTypeProbeHandle(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -17710,6 +17989,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem "OutPoint", "PaymentDetails", "PeerDetails", + "PendingBroadcastInfo", "ProbeHandle", "RouteHintHop", "RouteParametersConfig", diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index c58b0d716a..481ac2a4e0 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3355,6 +3355,8 @@ public func FfiConverterTypeOffer_lower(_ value: Offer) -> UnsafeMutableRawPoint } public protocol OnchainPaymentProtocol: AnyObject { + func abandonPendingBroadcast(txid: Txid) throws + func accelerateByCpfp(txid: Txid, feeRate: FeeRate?, destinationAddress: Address?) throws -> Txid func addressInfoForAccountAtIndex(addressType: AddressType, accountIndex: UInt32, keychain: KeychainKind, index: UInt32) throws -> AddressInfo @@ -3373,6 +3375,8 @@ public protocol OnchainPaymentProtocol: AnyObject { func calculateTotalFee(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> UInt64 + func listPendingBroadcasts() throws -> [PendingBroadcastInfo] + func listSpendableOutputs() throws -> [SpendableUtxo] func newAddress() throws -> Address @@ -3387,6 +3391,8 @@ public protocol OnchainPaymentProtocol: AnyObject { func newAddressInfoForType(addressType: AddressType) throws -> AddressInfo + func rebroadcastTransaction(txid: Txid) throws -> Txid + func revealReceiveAddressesTo(addressType: AddressType, index: UInt32) throws func revealReceiveAddressesToAccount(addressType: AddressType, accountIndex: UInt32, index: UInt32) throws @@ -3447,6 +3453,13 @@ open class OnchainPayment: try! rustCall { uniffi_ldk_node_fn_free_onchainpayment(pointer, $0) } } + open func abandonPendingBroadcast(txid: Txid) throws { + try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast(self.uniffiClonePointer(), + FfiConverterTypeTxid.lower(txid), $0) + } + } + open func accelerateByCpfp(txid: Txid, feeRate: FeeRate?, destinationAddress: Address?) throws -> Txid { return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp(self.uniffiClonePointer(), @@ -3531,6 +3544,12 @@ open class OnchainPayment: }) } + open func listPendingBroadcasts() throws -> [PendingBroadcastInfo] { + return try FfiConverterSequenceTypePendingBroadcastInfo.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts(self.uniffiClonePointer(), $0) + }) + } + open func listSpendableOutputs() throws -> [SpendableUtxo] { return try FfiConverterSequenceTypeSpendableUtxo.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs(self.uniffiClonePointer(), $0) @@ -3579,6 +3598,13 @@ open class OnchainPayment: }) } + open func rebroadcastTransaction(txid: Txid) throws -> Txid { + return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction(self.uniffiClonePointer(), + FfiConverterTypeTxid.lower(txid), $0) + }) + } + open func revealReceiveAddressesTo(addressType: AddressType, index: UInt32) throws { try rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to(self.uniffiClonePointer(), @@ -6955,6 +6981,67 @@ public func FfiConverterTypePeerDetails_lower(_ value: PeerDetails) -> RustBuffe return FfiConverterTypePeerDetails.lower(value) } +public struct PendingBroadcastInfo { + public var txid: Txid + public var lineage: [Txid] + + /// Default memberwise initializers are never public by default, so we + /// declare one manually. + public init(txid: Txid, lineage: [Txid]) { + self.txid = txid + self.lineage = lineage + } +} + +extension PendingBroadcastInfo: Equatable, Hashable { + public static func == (lhs: PendingBroadcastInfo, rhs: PendingBroadcastInfo) -> Bool { + if lhs.txid != rhs.txid { + return false + } + if lhs.lineage != rhs.lineage { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(txid) + hasher.combine(lineage) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypePendingBroadcastInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PendingBroadcastInfo { + return + try PendingBroadcastInfo( + txid: FfiConverterTypeTxid.read(from: &buf), + lineage: FfiConverterSequenceTypeTxid.read(from: &buf) + ) + } + + public static func write(_ value: PendingBroadcastInfo, into buf: inout [UInt8]) { + FfiConverterTypeTxid.write(value.txid, into: &buf) + FfiConverterSequenceTypeTxid.write(value.lineage, into: &buf) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypePendingBroadcastInfo_lift(_ buf: RustBuffer) throws -> PendingBroadcastInfo { + return try FfiConverterTypePendingBroadcastInfo.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypePendingBroadcastInfo_lower(_ value: PendingBroadcastInfo) -> RustBuffer { + return FfiConverterTypePendingBroadcastInfo.lower(value) +} + public struct ProbeHandle { public var paymentHash: PaymentHash public var paymentId: PaymentId @@ -9178,141 +9265,78 @@ public func FfiConverterTypeNetwork_lower(_ value: Network) -> RustBuffer { extension Network: Equatable, Hashable {} public enum NodeError { - case AlreadyRunning(message: String) - - case NotRunning(message: String) - - case OnchainTxCreationFailed(message: String) - - case ConnectionFailed(message: String) - - case InvoiceCreationFailed(message: String) - - case InvoiceRequestCreationFailed(message: String) - - case OfferCreationFailed(message: String) - - case RefundCreationFailed(message: String) - - case PaymentSendingFailed(message: String) - - case InvalidCustomTlvs(message: String) - - case ProbeSendingFailed(message: String) - - case RouteNotFound(message: String) - - case ChannelCreationFailed(message: String) - - case ChannelClosingFailed(message: String) - - case ChannelSplicingFailed(message: String) - - case ChannelConfigUpdateFailed(message: String) - - case PersistenceFailed(message: String) - - case FeerateEstimationUpdateFailed(message: String) - - case FeerateEstimationUpdateTimeout(message: String) - - case WalletOperationFailed(message: String) - - case WalletOperationTimeout(message: String) - - case OnchainTxSigningFailed(message: String) - - case TxSyncFailed(message: String) - - case TxSyncTimeout(message: String) - - case GossipUpdateFailed(message: String) - - case GossipUpdateTimeout(message: String) - - case LiquidityRequestFailed(message: String) - - case UriParameterParsingFailed(message: String) - - case InvalidAddress(message: String) - - case InvalidSocketAddress(message: String) - - case InvalidPublicKey(message: String) - - case InvalidSecretKey(message: String) - - case InvalidOfferId(message: String) - - case InvalidNodeId(message: String) - - case InvalidPaymentId(message: String) - - case InvalidPaymentHash(message: String) - - case InvalidPaymentPreimage(message: String) - - case InvalidPaymentSecret(message: String) - - case InvalidAmount(message: String) - - case InvalidInvoice(message: String) - - case InvalidOffer(message: String) - - case InvalidRefund(message: String) - - case InvalidChannelId(message: String) - - case InvalidNetwork(message: String) - - case InvalidUri(message: String) - - case InvalidQuantity(message: String) - - case InvalidNodeAlias(message: String) - - case InvalidDateTime(message: String) - - case InvalidFeeRate(message: String) - - case DuplicatePayment(message: String) - - case UnsupportedCurrency(message: String) - - case InsufficientFunds(message: String) - - case LiquiditySourceUnavailable(message: String) - - case LiquidityFeeTooHigh(message: String) - - case InvalidBlindedPaths(message: String) - - case AsyncPaymentServicesDisabled(message: String) - - case CannotRbfFundingTransaction(message: String) - - case TransactionNotFound(message: String) - - case TransactionAlreadyConfirmed(message: String) - - case NoSpendableOutputs(message: String) - - case CoinSelectionFailed(message: String) - - case InvalidMnemonic(message: String) - - case BackgroundSyncNotEnabled(message: String) - - case AddressTypeAlreadyMonitored(message: String) - - case AddressTypeIsPrimary(message: String) - - case AddressTypeNotMonitored(message: String) - - case OnchainWalletAccountNotRegistered(message: String) - - case InvalidSeedBytes(message: String) + case AlreadyRunning + case NotRunning + case OnchainTxCreationFailed + case ConnectionFailed + case InvoiceCreationFailed + case InvoiceRequestCreationFailed + case OfferCreationFailed + case RefundCreationFailed + case PaymentSendingFailed + case InvalidCustomTlvs + case ProbeSendingFailed + case RouteNotFound + case ChannelCreationFailed + case ChannelClosingFailed + case ChannelSplicingFailed + case ChannelConfigUpdateFailed + case PersistenceFailed + case FeerateEstimationUpdateFailed + case FeerateEstimationUpdateTimeout + case WalletOperationFailed + case WalletOperationTimeout + case OnchainTxSigningFailed + case TxSyncFailed + case TxSyncTimeout + case GossipUpdateFailed + case GossipUpdateTimeout + case LiquidityRequestFailed + case UriParameterParsingFailed + case InvalidAddress + case InvalidSocketAddress + case InvalidPublicKey + case InvalidSecretKey + case InvalidOfferId + case InvalidNodeId + case InvalidPaymentId + case InvalidPaymentHash + case InvalidPaymentPreimage + case InvalidPaymentSecret + case InvalidAmount + case InvalidInvoice + case InvalidOffer + case InvalidRefund + case InvalidChannelId + case InvalidNetwork + case InvalidUri + case InvalidQuantity + case InvalidNodeAlias + case InvalidDateTime + case InvalidFeeRate + case DuplicatePayment + case UnsupportedCurrency + case InsufficientFunds + case LiquiditySourceUnavailable + case LiquidityFeeTooHigh + case InvalidBlindedPaths + case AsyncPaymentServicesDisabled + case CannotRbfFundingTransaction + case TransactionNotFound + case TransactionAlreadyConfirmed + case NoSpendableOutputs + case CoinSelectionFailed + case InvalidMnemonic + case BackgroundSyncNotEnabled + case AddressTypeAlreadyMonitored + case AddressTypeIsPrimary + case AddressTypeNotMonitored + case OnchainWalletAccountNotRegistered + case InvalidSeedBytes + case OnchainTxBroadcastRejected(txid: Txid) + case OnchainTxBroadcastFailed(txid: Txid) + case OnchainTxBroadcastTimeout(txid: Txid) + case OnchainTxBroadcastNotDispatched(txid: Txid) } #if swift(>=5.8) @@ -9324,420 +9348,311 @@ public struct FfiConverterTypeNodeError: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NodeError { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return try .AlreadyRunning( - message: FfiConverterString.read(from: &buf) - ) - - case 2: return try .NotRunning( - message: FfiConverterString.read(from: &buf) - ) - - case 3: return try .OnchainTxCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 4: return try .ConnectionFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 5: return try .InvoiceCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 6: return try .InvoiceRequestCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 7: return try .OfferCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 8: return try .RefundCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 9: return try .PaymentSendingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 10: return try .InvalidCustomTlvs( - message: FfiConverterString.read(from: &buf) - ) - - case 11: return try .ProbeSendingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 12: return try .RouteNotFound( - message: FfiConverterString.read(from: &buf) - ) - - case 13: return try .ChannelCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 14: return try .ChannelClosingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 15: return try .ChannelSplicingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 16: return try .ChannelConfigUpdateFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 17: return try .PersistenceFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 18: return try .FeerateEstimationUpdateFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 19: return try .FeerateEstimationUpdateTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 20: return try .WalletOperationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 21: return try .WalletOperationTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 22: return try .OnchainTxSigningFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 23: return try .TxSyncFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 24: return try .TxSyncTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 25: return try .GossipUpdateFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 26: return try .GossipUpdateTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 27: return try .LiquidityRequestFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 28: return try .UriParameterParsingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 29: return try .InvalidAddress( - message: FfiConverterString.read(from: &buf) - ) - - case 30: return try .InvalidSocketAddress( - message: FfiConverterString.read(from: &buf) - ) - - case 31: return try .InvalidPublicKey( - message: FfiConverterString.read(from: &buf) - ) - - case 32: return try .InvalidSecretKey( - message: FfiConverterString.read(from: &buf) - ) - - case 33: return try .InvalidOfferId( - message: FfiConverterString.read(from: &buf) - ) - - case 34: return try .InvalidNodeId( - message: FfiConverterString.read(from: &buf) - ) - - case 35: return try .InvalidPaymentId( - message: FfiConverterString.read(from: &buf) - ) - - case 36: return try .InvalidPaymentHash( - message: FfiConverterString.read(from: &buf) - ) - - case 37: return try .InvalidPaymentPreimage( - message: FfiConverterString.read(from: &buf) - ) - - case 38: return try .InvalidPaymentSecret( - message: FfiConverterString.read(from: &buf) - ) - - case 39: return try .InvalidAmount( - message: FfiConverterString.read(from: &buf) - ) - - case 40: return try .InvalidInvoice( - message: FfiConverterString.read(from: &buf) - ) - - case 41: return try .InvalidOffer( - message: FfiConverterString.read(from: &buf) - ) - - case 42: return try .InvalidRefund( - message: FfiConverterString.read(from: &buf) - ) - - case 43: return try .InvalidChannelId( - message: FfiConverterString.read(from: &buf) - ) - - case 44: return try .InvalidNetwork( - message: FfiConverterString.read(from: &buf) - ) - - case 45: return try .InvalidUri( - message: FfiConverterString.read(from: &buf) - ) - - case 46: return try .InvalidQuantity( - message: FfiConverterString.read(from: &buf) - ) - - case 47: return try .InvalidNodeAlias( - message: FfiConverterString.read(from: &buf) - ) - - case 48: return try .InvalidDateTime( - message: FfiConverterString.read(from: &buf) - ) - - case 49: return try .InvalidFeeRate( - message: FfiConverterString.read(from: &buf) - ) - - case 50: return try .DuplicatePayment( - message: FfiConverterString.read(from: &buf) - ) - - case 51: return try .UnsupportedCurrency( - message: FfiConverterString.read(from: &buf) - ) - - case 52: return try .InsufficientFunds( - message: FfiConverterString.read(from: &buf) - ) - - case 53: return try .LiquiditySourceUnavailable( - message: FfiConverterString.read(from: &buf) - ) - - case 54: return try .LiquidityFeeTooHigh( - message: FfiConverterString.read(from: &buf) - ) - - case 55: return try .InvalidBlindedPaths( - message: FfiConverterString.read(from: &buf) - ) - - case 56: return try .AsyncPaymentServicesDisabled( - message: FfiConverterString.read(from: &buf) - ) - - case 57: return try .CannotRbfFundingTransaction( - message: FfiConverterString.read(from: &buf) - ) - - case 58: return try .TransactionNotFound( - message: FfiConverterString.read(from: &buf) - ) - - case 59: return try .TransactionAlreadyConfirmed( - message: FfiConverterString.read(from: &buf) - ) - - case 60: return try .NoSpendableOutputs( - message: FfiConverterString.read(from: &buf) - ) - - case 61: return try .CoinSelectionFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 62: return try .InvalidMnemonic( - message: FfiConverterString.read(from: &buf) + case 1: return .AlreadyRunning + case 2: return .NotRunning + case 3: return .OnchainTxCreationFailed + case 4: return .ConnectionFailed + case 5: return .InvoiceCreationFailed + case 6: return .InvoiceRequestCreationFailed + case 7: return .OfferCreationFailed + case 8: return .RefundCreationFailed + case 9: return .PaymentSendingFailed + case 10: return .InvalidCustomTlvs + case 11: return .ProbeSendingFailed + case 12: return .RouteNotFound + case 13: return .ChannelCreationFailed + case 14: return .ChannelClosingFailed + case 15: return .ChannelSplicingFailed + case 16: return .ChannelConfigUpdateFailed + case 17: return .PersistenceFailed + case 18: return .FeerateEstimationUpdateFailed + case 19: return .FeerateEstimationUpdateTimeout + case 20: return .WalletOperationFailed + case 21: return .WalletOperationTimeout + case 22: return .OnchainTxSigningFailed + case 23: return .TxSyncFailed + case 24: return .TxSyncTimeout + case 25: return .GossipUpdateFailed + case 26: return .GossipUpdateTimeout + case 27: return .LiquidityRequestFailed + case 28: return .UriParameterParsingFailed + case 29: return .InvalidAddress + case 30: return .InvalidSocketAddress + case 31: return .InvalidPublicKey + case 32: return .InvalidSecretKey + case 33: return .InvalidOfferId + case 34: return .InvalidNodeId + case 35: return .InvalidPaymentId + case 36: return .InvalidPaymentHash + case 37: return .InvalidPaymentPreimage + case 38: return .InvalidPaymentSecret + case 39: return .InvalidAmount + case 40: return .InvalidInvoice + case 41: return .InvalidOffer + case 42: return .InvalidRefund + case 43: return .InvalidChannelId + case 44: return .InvalidNetwork + case 45: return .InvalidUri + case 46: return .InvalidQuantity + case 47: return .InvalidNodeAlias + case 48: return .InvalidDateTime + case 49: return .InvalidFeeRate + case 50: return .DuplicatePayment + case 51: return .UnsupportedCurrency + case 52: return .InsufficientFunds + case 53: return .LiquiditySourceUnavailable + case 54: return .LiquidityFeeTooHigh + case 55: return .InvalidBlindedPaths + case 56: return .AsyncPaymentServicesDisabled + case 57: return .CannotRbfFundingTransaction + case 58: return .TransactionNotFound + case 59: return .TransactionAlreadyConfirmed + case 60: return .NoSpendableOutputs + case 61: return .CoinSelectionFailed + case 62: return .InvalidMnemonic + case 63: return .BackgroundSyncNotEnabled + case 64: return .AddressTypeAlreadyMonitored + case 65: return .AddressTypeIsPrimary + case 66: return .AddressTypeNotMonitored + case 67: return .OnchainWalletAccountNotRegistered + case 68: return .InvalidSeedBytes + case 69: return try .OnchainTxBroadcastRejected( + txid: FfiConverterTypeTxid.read(from: &buf) + ) + case 70: return try .OnchainTxBroadcastFailed( + txid: FfiConverterTypeTxid.read(from: &buf) + ) + case 71: return try .OnchainTxBroadcastTimeout( + txid: FfiConverterTypeTxid.read(from: &buf) + ) + case 72: return try .OnchainTxBroadcastNotDispatched( + txid: FfiConverterTypeTxid.read(from: &buf) ) - - case 63: return try .BackgroundSyncNotEnabled( - message: FfiConverterString.read(from: &buf) - ) - - case 64: return try .AddressTypeAlreadyMonitored( - message: FfiConverterString.read(from: &buf) - ) - - case 65: return try .AddressTypeIsPrimary( - message: FfiConverterString.read(from: &buf) - ) - - case 66: return try .AddressTypeNotMonitored( - message: FfiConverterString.read(from: &buf) - ) - - case 67: return try .OnchainWalletAccountNotRegistered( - message: FfiConverterString.read(from: &buf) - ) - - case 68: return try .InvalidSeedBytes( - message: FfiConverterString.read(from: &buf) - ) - default: throw UniffiInternalError.unexpectedEnumCase } } public static func write(_ value: NodeError, into buf: inout [UInt8]) { switch value { - case .AlreadyRunning(_ /* message is ignored*/ ): + case .AlreadyRunning: writeInt(&buf, Int32(1)) - case .NotRunning(_ /* message is ignored*/ ): + + case .NotRunning: writeInt(&buf, Int32(2)) - case .OnchainTxCreationFailed(_ /* message is ignored*/ ): + + case .OnchainTxCreationFailed: writeInt(&buf, Int32(3)) - case .ConnectionFailed(_ /* message is ignored*/ ): + + case .ConnectionFailed: writeInt(&buf, Int32(4)) - case .InvoiceCreationFailed(_ /* message is ignored*/ ): + + case .InvoiceCreationFailed: writeInt(&buf, Int32(5)) - case .InvoiceRequestCreationFailed(_ /* message is ignored*/ ): + + case .InvoiceRequestCreationFailed: writeInt(&buf, Int32(6)) - case .OfferCreationFailed(_ /* message is ignored*/ ): + + case .OfferCreationFailed: writeInt(&buf, Int32(7)) - case .RefundCreationFailed(_ /* message is ignored*/ ): + + case .RefundCreationFailed: writeInt(&buf, Int32(8)) - case .PaymentSendingFailed(_ /* message is ignored*/ ): + + case .PaymentSendingFailed: writeInt(&buf, Int32(9)) - case .InvalidCustomTlvs(_ /* message is ignored*/ ): + + case .InvalidCustomTlvs: writeInt(&buf, Int32(10)) - case .ProbeSendingFailed(_ /* message is ignored*/ ): + + case .ProbeSendingFailed: writeInt(&buf, Int32(11)) - case .RouteNotFound(_ /* message is ignored*/ ): + + case .RouteNotFound: writeInt(&buf, Int32(12)) - case .ChannelCreationFailed(_ /* message is ignored*/ ): + + case .ChannelCreationFailed: writeInt(&buf, Int32(13)) - case .ChannelClosingFailed(_ /* message is ignored*/ ): + + case .ChannelClosingFailed: writeInt(&buf, Int32(14)) - case .ChannelSplicingFailed(_ /* message is ignored*/ ): + + case .ChannelSplicingFailed: writeInt(&buf, Int32(15)) - case .ChannelConfigUpdateFailed(_ /* message is ignored*/ ): + + case .ChannelConfigUpdateFailed: writeInt(&buf, Int32(16)) - case .PersistenceFailed(_ /* message is ignored*/ ): + + case .PersistenceFailed: writeInt(&buf, Int32(17)) - case .FeerateEstimationUpdateFailed(_ /* message is ignored*/ ): + + case .FeerateEstimationUpdateFailed: writeInt(&buf, Int32(18)) - case .FeerateEstimationUpdateTimeout(_ /* message is ignored*/ ): + + case .FeerateEstimationUpdateTimeout: writeInt(&buf, Int32(19)) - case .WalletOperationFailed(_ /* message is ignored*/ ): + + case .WalletOperationFailed: writeInt(&buf, Int32(20)) - case .WalletOperationTimeout(_ /* message is ignored*/ ): + + case .WalletOperationTimeout: writeInt(&buf, Int32(21)) - case .OnchainTxSigningFailed(_ /* message is ignored*/ ): + + case .OnchainTxSigningFailed: writeInt(&buf, Int32(22)) - case .TxSyncFailed(_ /* message is ignored*/ ): + + case .TxSyncFailed: writeInt(&buf, Int32(23)) - case .TxSyncTimeout(_ /* message is ignored*/ ): + + case .TxSyncTimeout: writeInt(&buf, Int32(24)) - case .GossipUpdateFailed(_ /* message is ignored*/ ): + + case .GossipUpdateFailed: writeInt(&buf, Int32(25)) - case .GossipUpdateTimeout(_ /* message is ignored*/ ): + + case .GossipUpdateTimeout: writeInt(&buf, Int32(26)) - case .LiquidityRequestFailed(_ /* message is ignored*/ ): + + case .LiquidityRequestFailed: writeInt(&buf, Int32(27)) - case .UriParameterParsingFailed(_ /* message is ignored*/ ): + + case .UriParameterParsingFailed: writeInt(&buf, Int32(28)) - case .InvalidAddress(_ /* message is ignored*/ ): + + case .InvalidAddress: writeInt(&buf, Int32(29)) - case .InvalidSocketAddress(_ /* message is ignored*/ ): + + case .InvalidSocketAddress: writeInt(&buf, Int32(30)) - case .InvalidPublicKey(_ /* message is ignored*/ ): + + case .InvalidPublicKey: writeInt(&buf, Int32(31)) - case .InvalidSecretKey(_ /* message is ignored*/ ): + + case .InvalidSecretKey: writeInt(&buf, Int32(32)) - case .InvalidOfferId(_ /* message is ignored*/ ): + + case .InvalidOfferId: writeInt(&buf, Int32(33)) - case .InvalidNodeId(_ /* message is ignored*/ ): + + case .InvalidNodeId: writeInt(&buf, Int32(34)) - case .InvalidPaymentId(_ /* message is ignored*/ ): + + case .InvalidPaymentId: writeInt(&buf, Int32(35)) - case .InvalidPaymentHash(_ /* message is ignored*/ ): + + case .InvalidPaymentHash: writeInt(&buf, Int32(36)) - case .InvalidPaymentPreimage(_ /* message is ignored*/ ): + + case .InvalidPaymentPreimage: writeInt(&buf, Int32(37)) - case .InvalidPaymentSecret(_ /* message is ignored*/ ): + + case .InvalidPaymentSecret: writeInt(&buf, Int32(38)) - case .InvalidAmount(_ /* message is ignored*/ ): + + case .InvalidAmount: writeInt(&buf, Int32(39)) - case .InvalidInvoice(_ /* message is ignored*/ ): + + case .InvalidInvoice: writeInt(&buf, Int32(40)) - case .InvalidOffer(_ /* message is ignored*/ ): + + case .InvalidOffer: writeInt(&buf, Int32(41)) - case .InvalidRefund(_ /* message is ignored*/ ): + + case .InvalidRefund: writeInt(&buf, Int32(42)) - case .InvalidChannelId(_ /* message is ignored*/ ): + + case .InvalidChannelId: writeInt(&buf, Int32(43)) - case .InvalidNetwork(_ /* message is ignored*/ ): + + case .InvalidNetwork: writeInt(&buf, Int32(44)) - case .InvalidUri(_ /* message is ignored*/ ): + + case .InvalidUri: writeInt(&buf, Int32(45)) - case .InvalidQuantity(_ /* message is ignored*/ ): + + case .InvalidQuantity: writeInt(&buf, Int32(46)) - case .InvalidNodeAlias(_ /* message is ignored*/ ): + + case .InvalidNodeAlias: writeInt(&buf, Int32(47)) - case .InvalidDateTime(_ /* message is ignored*/ ): + + case .InvalidDateTime: writeInt(&buf, Int32(48)) - case .InvalidFeeRate(_ /* message is ignored*/ ): + + case .InvalidFeeRate: writeInt(&buf, Int32(49)) - case .DuplicatePayment(_ /* message is ignored*/ ): + + case .DuplicatePayment: writeInt(&buf, Int32(50)) - case .UnsupportedCurrency(_ /* message is ignored*/ ): + + case .UnsupportedCurrency: writeInt(&buf, Int32(51)) - case .InsufficientFunds(_ /* message is ignored*/ ): + + case .InsufficientFunds: writeInt(&buf, Int32(52)) - case .LiquiditySourceUnavailable(_ /* message is ignored*/ ): + + case .LiquiditySourceUnavailable: writeInt(&buf, Int32(53)) - case .LiquidityFeeTooHigh(_ /* message is ignored*/ ): + + case .LiquidityFeeTooHigh: writeInt(&buf, Int32(54)) - case .InvalidBlindedPaths(_ /* message is ignored*/ ): + + case .InvalidBlindedPaths: writeInt(&buf, Int32(55)) - case .AsyncPaymentServicesDisabled(_ /* message is ignored*/ ): + + case .AsyncPaymentServicesDisabled: writeInt(&buf, Int32(56)) - case .CannotRbfFundingTransaction(_ /* message is ignored*/ ): + + case .CannotRbfFundingTransaction: writeInt(&buf, Int32(57)) - case .TransactionNotFound(_ /* message is ignored*/ ): + + case .TransactionNotFound: writeInt(&buf, Int32(58)) - case .TransactionAlreadyConfirmed(_ /* message is ignored*/ ): + + case .TransactionAlreadyConfirmed: writeInt(&buf, Int32(59)) - case .NoSpendableOutputs(_ /* message is ignored*/ ): + + case .NoSpendableOutputs: writeInt(&buf, Int32(60)) - case .CoinSelectionFailed(_ /* message is ignored*/ ): + + case .CoinSelectionFailed: writeInt(&buf, Int32(61)) - case .InvalidMnemonic(_ /* message is ignored*/ ): + + case .InvalidMnemonic: writeInt(&buf, Int32(62)) - case .BackgroundSyncNotEnabled(_ /* message is ignored*/ ): + + case .BackgroundSyncNotEnabled: writeInt(&buf, Int32(63)) - case .AddressTypeAlreadyMonitored(_ /* message is ignored*/ ): + + case .AddressTypeAlreadyMonitored: writeInt(&buf, Int32(64)) - case .AddressTypeIsPrimary(_ /* message is ignored*/ ): + + case .AddressTypeIsPrimary: writeInt(&buf, Int32(65)) - case .AddressTypeNotMonitored(_ /* message is ignored*/ ): + + case .AddressTypeNotMonitored: writeInt(&buf, Int32(66)) - case .OnchainWalletAccountNotRegistered(_ /* message is ignored*/ ): + + case .OnchainWalletAccountNotRegistered: writeInt(&buf, Int32(67)) - case .InvalidSeedBytes(_ /* message is ignored*/ ): + + case .InvalidSeedBytes: writeInt(&buf, Int32(68)) + + case let .OnchainTxBroadcastRejected(txid): + writeInt(&buf, Int32(69)) + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .OnchainTxBroadcastFailed(txid): + writeInt(&buf, Int32(70)) + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .OnchainTxBroadcastTimeout(txid): + writeInt(&buf, Int32(71)) + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .OnchainTxBroadcastNotDispatched(txid): + writeInt(&buf, Int32(72)) + FfiConverterTypeTxid.write(txid, into: &buf) } } } @@ -11791,6 +11706,31 @@ private struct FfiConverterSequenceTypePeerDetails: FfiConverterRustBuffer { } } +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +private struct FfiConverterSequenceTypePendingBroadcastInfo: FfiConverterRustBuffer { + typealias SwiftType = [PendingBroadcastInfo] + + static func write(_ value: [PendingBroadcastInfo], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePendingBroadcastInfo.write(item, into: &buf) + } + } + + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PendingBroadcastInfo] { + let len: Int32 = try readInt(&buf) + var seq = [PendingBroadcastInfo]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + try seq.append(FfiConverterTypePendingBroadcastInfo.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -13563,6 +13503,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_offer_supports_chain() != 2135 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954 { return InitializationResult.apiChecksumMismatch } @@ -13590,6 +13533,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts() != 40346 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144 { return InitializationResult.apiChecksumMismatch } @@ -13611,6 +13557,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type() != 62171 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction() != 36642 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to() != 44189 { return InitializationResult.apiChecksumMismatch } diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs index 6215359f17..5d3a15251b 100644 --- a/crates/bdk-wallet-aggregate/src/lib.rs +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -26,6 +26,7 @@ use std::fmt::Debug; use std::hash::Hash; use std::ops::{Deref, DerefMut}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::{ChainPosition, ConfirmationBlockTime}; @@ -625,6 +626,57 @@ where Ok(()) } + /// Mark a locally prepared transaction as no longer intended for broadcast. + /// + /// The transaction is evicted from the active graph and its inputs are released for a new send. + pub fn abandon_tx(&mut self, tx: &Transaction) -> Result<(), Error> { + self.abandon_txs(std::slice::from_ref(tx)) + } + + /// Mark a transaction and all of its locally prepared replacements as abandoned. + /// + /// Every transaction is evicted and cancelled before each wallet is persisted, so conflicting + /// replacements cannot leave the same input reserved after explicit reconciliation. + pub fn abandon_txs(&mut self, txs: &[Transaction]) -> Result<(), Error> { + let txids = txs.iter().map(Transaction::compute_txid).collect::>(); + let last_seen = self.next_transaction_update_timestamp(&txids)?; + let evicted_txs = txs.iter().map(|tx| (tx.compute_txid(), last_seen)).collect::>(); + for (key, wallet) in self.wallets.iter_mut() { + wallet.apply_evicted_txs(evicted_txs.iter().copied()); + for tx in txs { + wallet.cancel_tx(tx); + } + let persister = self.persisters.get_mut(key).ok_or(Error::PersisterNotFound)?; + wallet.persist(persister).map_err(|e| { + log::error!("Failed to persist wallet {:?}: {}", key, e); + Error::PersistenceFailed + })?; + } + Ok(()) + } + + fn next_transaction_update_timestamp(&self, txids: &[Txid]) -> Result { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let tracked_txids = txids.iter().copied().collect::>(); + let latest_seen = self + .unconfirmed_txids_with_last_seen() + .into_iter() + .filter(|(txid, _)| tracked_txids.contains(txid)) + .map(|(_, last_seen)| last_seen) + .max() + .unwrap_or(0); + let latest_evicted = self + .wallets + .values() + .flat_map(|wallet| { + txids.iter().filter_map(|txid| wallet.tx_graph().get_last_evicted(*txid)) + }) + .max() + .unwrap_or(0); + + now.max(latest_seen).max(latest_evicted).checked_add(1).ok_or(Error::WalletOperationFailed) + } + /// Cancel a dry-run transaction on the primary wallet without persisting. /// /// Unmarks change addresses that were marked "used" by `finish()`, so @@ -1839,6 +1891,152 @@ mod tests { ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20])) } + #[test] + fn signed_transaction_is_not_recorded_until_observed_on_chain() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + aggregate.persist_all().unwrap(); + + let txid = tx.compute_txid(); + assert!(aggregate + .wallets() + .values() + .flat_map(|wallet| wallet.transactions()) + .all(|wallet_tx| wallet_tx.tx_node.txid != txid)); + } + + #[test] + fn pending_broadcast_is_recoverable_until_abandoned() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + + aggregate.apply_mempool_txs(vec![(tx.clone(), 1)], Vec::new()).unwrap(); + assert_eq!(aggregate.find_tx(txid), Some(tx.clone())); + assert!(aggregate.unconfirmed_txids().contains(&txid)); + + aggregate.abandon_tx(&tx).unwrap(); + assert_eq!(aggregate.find_tx(txid), None); + assert!(!aggregate.unconfirmed_txids().contains(&txid)); + assert!(aggregate + .list_unspent() + .iter() + .any(|output| output.outpoint == tx.input[0].previous_output)); + } + + #[test] + fn abandonment_is_newer_than_the_latest_transaction_observation() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + let latest_observation = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(60); + + aggregate.apply_mempool_txs(vec![(tx.clone(), latest_observation)], Vec::new()).unwrap(); + aggregate.abandon_tx(&tx).unwrap(); + + let latest_eviction = aggregate + .wallets() + .values() + .filter_map(|wallet| wallet.tx_graph().get_last_evicted(txid)) + .max() + .unwrap(); + assert_eq!(latest_eviction, latest_observation + 1); + assert_eq!(aggregate.find_tx(txid), None); + } + + #[test] + fn abandonment_fails_closed_when_timestamp_cannot_advance() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + + aggregate.apply_mempool_txs(vec![(tx.clone(), u64::MAX)], Vec::new()).unwrap(); + + assert_eq!(aggregate.abandon_tx(&tx), Err(Error::WalletOperationFailed)); + assert_eq!(aggregate.find_tx(txid), Some(tx)); + } + + #[test] + fn pending_broadcast_reapplication_must_be_newer_than_an_equal_eviction() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + let spent_outpoint = tx.input[0].previous_output; + + aggregate.apply_mempool_txs(vec![(tx.clone(), 7)], Vec::new()).unwrap(); + aggregate.apply_mempool_txs(Vec::new(), vec![(txid, 7)]).unwrap(); + aggregate.apply_mempool_txs(vec![(tx.clone(), 7)], Vec::new()).unwrap(); + + assert_eq!(aggregate.find_tx(txid), None); + assert!(aggregate.list_unspent().iter().any(|output| output.outpoint == spent_outpoint)); + + aggregate.apply_mempool_txs(vec![(tx.clone(), 8)], Vec::new()).unwrap(); + + assert_eq!(aggregate.find_tx(txid), Some(tx)); + assert!(!aggregate.list_unspent().iter().any(|output| output.outpoint == spent_outpoint)); + } + + #[test] + fn pending_broadcast_survives_wallet_reload_for_exact_rebroadcast() { + let mut persister = MemoryPersister::default(); + let mut wallet = create_empty_wallet(&mut persister); + fund_wallet(&mut wallet, Amount::from_sat(100_000), 0x01); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + aggregate.apply_mempool_txs(vec![(tx.clone(), 1)], Vec::new()).unwrap(); + + let mut reload_persister = aggregate.persisters().get(&0).unwrap().clone(); + drop(aggregate); + let reloaded_wallet = load_empty_wallet(&mut reload_persister); + let reloaded = AggregateWallet::new(reloaded_wallet, reload_persister, 0u8, vec![]); + + assert_eq!(reloaded.find_tx(txid), Some(tx)); + assert!(reloaded.unconfirmed_txids().contains(&txid)); + } + #[test] fn persistence_fails_when_a_wallet_has_no_persister() { let mut persister = NoopPersister; diff --git a/src/builder.rs b/src/builder.rs index 67cb9afec8..817b513e43 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1970,6 +1970,10 @@ fn build_with_store_internal( Arc::clone(&logger), derived_account_lookahead, )); + wallet.restore_pending_broadcasts().map_err(|e| { + log_error!(logger, "Failed to restore pending on-chain broadcasts: {}", e); + BuildError::WalletSetupFailed + })?; // Initialize the KeysManager let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| { diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 7467af86f7..a4699d92d4 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -38,6 +38,9 @@ use crate::fee_estimator::{ }; use crate::io::utils::write_node_metrics; use crate::logger::{log_bytes, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::tx_broadcaster::{ + classify_rpc_broadcast_error, validate_broadcast_txid, TxBroadcastError, +}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; @@ -643,30 +646,58 @@ impl BitcoindChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_broadcast_package( + &self, package: Vec, + ) -> Result<(), TxBroadcastError> { // 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. + let mut package_result = Ok(()); 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 { + let tx_result = match timeout_fut.await { Ok(res) => match res { Ok(id) => { - debug_assert_eq!(id, txid); - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + let result = classify_bitcoind_broadcast_success(txid, id); + if result.is_ok() { + log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + } else { + log_error!( + self.logger, + "Backend returned transaction ID {} for submitted transaction {}", + id, + txid + ); + } + result }, Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); + let result = classify_bitcoind_broadcast_error(&e); + if result.is_ok() { + log_trace!( + self.logger, + "Transaction {} is already known by backend", + txid + ); + } else { + log_error!( + self.logger, + "Failed to broadcast transaction {}: {}", + txid, + e + ); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + } + result }, }, Err(e) => { @@ -681,9 +712,27 @@ impl BitcoindChainSource { "Failed broadcast transaction bytes: {}", log_bytes!(tx.encode()) ); + Err(TxBroadcastError::Timeout) }, + }; + if package_result.is_ok() { + package_result = tx_result; } } + package_result + } +} + +fn classify_bitcoind_broadcast_success( + expected_txid: Txid, returned_txid: Txid, +) -> Result<(), TxBroadcastError> { + validate_broadcast_txid(expected_txid, returned_txid) +} + +fn classify_bitcoind_broadcast_error(error: &std::io::Error) -> Result<(), TxBroadcastError> { + match error.get_ref().and_then(|inner| inner.downcast_ref::()) { + Some(rpc_error) => classify_rpc_broadcast_error(Some(rpc_error.code), &rpc_error.message), + None => Err(TxBroadcastError::Failed), } } @@ -1607,6 +1656,7 @@ impl std::fmt::Display for HttpError { #[cfg(test)] mod tests { + use std::io; use std::sync::Arc; use bitcoin::blockdata::constants::genesis_block; @@ -1614,6 +1664,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 +1672,54 @@ mod tests { use crate::builder::NodeBuilder; use crate::chain::bitcoind::{ + classify_bitcoind_broadcast_error, classify_bitcoind_broadcast_success, should_emit_mempool_entry, AccountChainListener, AccountChainListenerOutcome, BitcoindClient, FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, GetRawTransactionResponse, MempoolMinFeeResponse, MempoolUpdate, }; use crate::config::{AddressType, Config, OnchainWalletAccount}; use crate::io::test_utils::InMemoryStore; + use crate::tx_broadcaster::TxBroadcastError; use crate::types::DynStore; use crate::Error; + #[test] + fn bitcoind_broadcast_errors_are_classified_by_rpc_code() { + let already_known = io::Error::new( + io::ErrorKind::Other, + RpcError { code: -27, message: "Transaction already in block chain".to_string() }, + ); + assert_eq!(classify_bitcoind_broadcast_error(&already_known), Ok(())); + + let rejected = io::Error::new( + io::ErrorKind::Other, + RpcError { code: -26, message: "non-final".to_string() }, + ); + assert_eq!(classify_bitcoind_broadcast_error(&rejected), Err(TxBroadcastError::Rejected)); + + let unavailable = io::Error::new( + io::ErrorKind::Other, + RpcError { code: -28, message: "Loading block index".to_string() }, + ); + assert_eq!(classify_bitcoind_broadcast_error(&unavailable), Err(TxBroadcastError::Failed)); + } + + #[test] + fn bitcoind_broadcast_success_requires_the_submitted_txid() { + let expected = "0000000000000000000000000000000000000000000000000000000000000001" + .parse::() + .unwrap(); + let returned = "0000000000000000000000000000000000000000000000000000000000000002" + .parse::() + .unwrap(); + + assert_eq!(classify_bitcoind_broadcast_success(expected, expected), Ok(())); + assert_eq!( + classify_bitcoind_broadcast_success(expected, returned), + Err(TxBroadcastError::Failed) + ); + } + 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..968fd0df0c 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -32,7 +32,7 @@ use crate::config::{ AddressTypeRuntimeConfig, Config, ElectrumSyncConfig, BDK_CLIENT_STOP_GAP, BDK_ELECTRUM_CLIENT_BATCH_SIZE, BDK_WALLET_SYNC_TIMEOUT_SECS, DEFAULT_ELECTRUM_CONNECTION_TIMEOUT_SECS, FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS, - LDK_WALLET_SYNC_TIMEOUT_SECS, TX_BROADCAST_TIMEOUT_SECS, + LDK_WALLET_SYNC_TIMEOUT_SECS, }; use crate::error::Error; use crate::fee_estimator::{ @@ -41,6 +41,9 @@ use crate::fee_estimator::{ }; use crate::io::utils::write_node_metrics; use crate::logger::{log_bytes, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::tx_broadcaster::{ + classify_rpc_broadcast_error, validate_broadcast_txid, TxBroadcastError, +}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::NodeMetrics; @@ -524,18 +527,25 @@ impl ElectrumChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_broadcast_package( + &self, package: Vec, + ) -> Result<(), TxBroadcastError> { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().unwrap().client().as_ref() { Arc::clone(client) } else { debug_assert!(false, "We should have started the chain source before broadcasting"); - return; + return Err(TxBroadcastError::Failed); }; + let mut package_result = Ok(()); for tx in package { - electrum_client.broadcast(tx).await; + let result = electrum_client.broadcast(tx).await; + if package_result.is_ok() { + package_result = result; + } } + package_result } pub(super) async fn get_address_balance(&self, address: &bitcoin::Address) -> Option { @@ -831,7 +841,7 @@ impl ElectrumRuntimeClient { }) } - async fn broadcast(&self, tx: Transaction) { + async fn broadcast(&self, tx: Transaction) -> Result<(), TxBroadcastError> { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); @@ -839,35 +849,46 @@ impl ElectrumRuntimeClient { 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); + match spawn_fut.await { + Ok(broadcast_result) => { + let result = classify_electrum_broadcast_result(txid, &broadcast_result); + match (&broadcast_result, &result) { + (Ok(_), Ok(())) => { + log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + }, + (Err(_), Ok(())) => { + log_trace!(self.logger, "Transaction {} is already known by backend", txid); + }, + (Ok(id), Err(_)) => { + log_error!( + self.logger, + "Backend returned transaction ID {} for submitted transaction {}", + id, + txid + ); + }, + (Err(e), Err(_)) => { + log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); + }, + } + if result.is_err() { log_trace!( self.logger, "Failed broadcast transaction bytes: {}", log_bytes!(tx_bytes) ); - }, + } + result }, Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); + log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); log_trace!( self.logger, "Failed broadcast transaction bytes: {}", log_bytes!(tx_bytes) ); + Err(TxBroadcastError::Failed) }, } } @@ -957,6 +978,19 @@ impl ElectrumRuntimeClient { } } +fn classify_electrum_broadcast_result( + expected_txid: Txid, result: &Result, +) -> Result<(), TxBroadcastError> { + match result { + Ok(returned_txid) => validate_broadcast_txid(expected_txid, *returned_txid), + Err(electrum_client::Error::Protocol(value)) => { + let code = value.get("code").and_then(serde_json::Value::as_i64); + classify_rpc_broadcast_error(code, &value.to_string()) + }, + Err(_) => Err(TxBroadcastError::Failed), + } +} + struct ConfirmGate { active: AtomicBool, } @@ -1053,6 +1087,7 @@ impl Filter for ElectrumRuntimeClient { #[cfg(test)] mod tests { + use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::process::Command; @@ -1063,6 +1098,9 @@ mod tests { use bitcoin::blockdata::constants::genesis_block; + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use super::*; use crate::runtime::Runtime; @@ -1148,6 +1186,95 @@ mod tests { } } + #[test] + fn electrum_nested_broadcast_results_are_classified() { + let txid = "0000000000000000000000000000000000000000000000000000000000000001" + .parse::() + .unwrap(); + let other_txid = "0000000000000000000000000000000000000000000000000000000000000002" + .parse::() + .unwrap(); + + assert_eq!(classify_electrum_broadcast_result(txid, &Ok(txid)), Ok(())); + assert_eq!( + classify_electrum_broadcast_result(txid, &Ok(other_txid)), + Err(TxBroadcastError::Failed) + ); + assert_eq!( + classify_electrum_broadcast_result( + txid, + &Err(electrum_client::Error::Protocol(serde_json::json!({ + "code": -27, + "message": "Transaction already in block chain" + }))), + ), + Ok(()) + ); + assert_eq!( + classify_electrum_broadcast_result( + txid, + &Err(electrum_client::Error::Protocol(serde_json::json!({ + "code": -26, + "message": "non-final" + }))), + ), + Err(TxBroadcastError::Rejected) + ); + assert_eq!( + classify_electrum_broadcast_result( + txid, + &Err(electrum_client::Error::Protocol(serde_json::json!({ + "code": -32603, + "message": "internal server error" + }))), + ), + Err(TxBroadcastError::Failed) + ); + } + + #[test] + fn electrum_protocol_rejection_propagates_through_broadcast_worker() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let server_url = format!("tcp://{}", listener.local_addr().unwrap()); + let server_thread = thread::spawn(move || { + let (broadcast_stream, _) = listener.accept().unwrap(); + let (_sync_stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(broadcast_stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&request_line).unwrap(); + assert_eq!(request["method"], "blockchain.transaction.broadcast"); + + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -26, "message": "non-final" }, + }); + let mut response_stream = broadcast_stream; + writeln!(response_stream, "{}", response).unwrap(); + }); + + let logger = Arc::new(Logger::new_log_facade()); + let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).unwrap()); + let client = ElectrumRuntimeClient::new( + server_url, + runtime.handle().clone(), + Arc::new(Config::default()), + logger, + 1, + ) + .unwrap(); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + }; + + assert_eq!(runtime.block_on(client.broadcast(tx)), Err(TxBroadcastError::Rejected)); + server_thread.join().unwrap(); + } + #[test] fn inflight_electrum_worker_does_not_own_runtime_lifecycle() { if std::env::var_os(RUNTIME_SELF_DROP_CHILD_ENV).is_some() { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 8de1a23962..10625f0e0f 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -29,6 +29,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::write_node_metrics; use crate::logger::{log_bytes, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::tx_broadcaster::{classify_rpc_broadcast_error, TxBroadcastError}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; @@ -479,58 +480,61 @@ impl EsploraChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_broadcast_package( + &self, package: Vec, + ) -> Result<(), TxBroadcastError> { + let mut package_result = Ok(()); 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 { + let tx_result = match timeout_fut.await { Ok(res) => match res { Ok(()) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + Ok(()) }, - 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 - ); + Err(e) => { + let result = classify_esplora_broadcast_error(&e); + if result.is_ok() { log_trace!( self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) + "Transaction {} is already known by backend", + txid ); - }, + } else { + match e { + esplora_client::Error::HttpResponse { status, message } => { + log_error!( + self.logger, + "Failed to broadcast due to HTTP response: {} - {}", + 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()) + ); + }, + } + } + result }, }, Err(e) => { @@ -545,9 +549,14 @@ impl EsploraChainSource { "Failed broadcast transaction bytes: {}", log_bytes!(tx.encode()) ); + Err(TxBroadcastError::Timeout) }, + }; + if package_result.is_ok() { + package_result = tx_result; } } + package_result } pub(super) async fn get_address_balance(&self, address: &bitcoin::Address) -> Option { @@ -576,6 +585,15 @@ impl EsploraChainSource { } } +fn classify_esplora_broadcast_error(error: &esplora_client::Error) -> Result<(), TxBroadcastError> { + match error { + esplora_client::Error::HttpResponse { status: 400, message } => { + classify_rpc_broadcast_error(None, message) + }, + _ => Err(TxBroadcastError::Failed), + } +} + impl Filter for EsploraChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { self.tx_sync.register_tx(txid, script_pubkey); @@ -584,3 +602,31 @@ impl Filter for EsploraChainSource { self.tx_sync.register_output(output); } } + +#[cfg(test)] +mod tests { + use super::classify_esplora_broadcast_error; + use crate::tx_broadcaster::TxBroadcastError; + + #[test] + fn esplora_http_responses_distinguish_known_rejections_and_failures() { + let already_known = esplora_client::Error::HttpResponse { + status: 400, + message: "Transaction already in block chain".to_string(), + }; + assert_eq!(classify_esplora_broadcast_error(&already_known), Ok(())); + + let rejected = esplora_client::Error::HttpResponse { + status: 400, + message: r#"sendrawtransaction RPC error: {"code":-26,"message":"non-final"}"# + .to_string(), + }; + assert_eq!(classify_esplora_broadcast_error(&rejected), Err(TxBroadcastError::Rejected)); + + let unavailable = esplora_client::Error::HttpResponse { + status: 503, + message: "service unavailable".to_string(), + }; + assert_eq!(classify_esplora_broadcast_error(&unavailable), Err(TxBroadcastError::Failed)); + } +} diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 4a20065f59..28885d1c6a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -565,6 +565,29 @@ where }, } } + + for txid in wallet.take_locally_applied_unconfirmed_txids() { + if !seen_received_txids.insert(txid) { + continue; + } + if seen_confirmed_txids.contains(&txid) || transaction_confirmations.contains_key(&txid) { + continue; + } + let Some(details) = get_transaction_details(&txid, wallet, channel_manager) else { + continue; + }; + log_info!( + logger, + "New unconfirmed transaction {} detected in mempool (amount: {} sats)", + txid, + details.amount_sats + ); + let event = Event::OnchainTransactionReceived { txid, details }; + event_queue.add_event(event).await.map_err(|e| { + log_error!(logger, "Failed to push onchain event to queue: {}", e); + e + })?; + } Ok(()) } @@ -1295,28 +1318,35 @@ impl ChainSource { pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { - let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; + let mut receivers = self.tx_broadcaster.get_broadcast_queue_receivers().await; loop { let tx_bcast_logger = Arc::clone(&self.logger); tokio::select! { _ = stop_tx_bcast_receiver.changed() => { + self.tx_broadcaster.pause_explicit_broadcasts(); + receivers.fail_queued_explicit_requests(); log_debug!( tx_bcast_logger, "Stopping broadcasting transactions.", ); return; } - Some(next_package) = receiver.recv() => { - match &self.kind { + Some(request) = receivers.recv() => { + let package = request.package; + let result_sender = request.result_sender; + let result = match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_broadcast_package(package).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_broadcast_package(package).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_broadcast_package(package).await }, + }; + if let Some(result_sender) = result_sender { + let _ = result_sender.send(result); } } } diff --git a/src/data_store.rs b/src/data_store.rs index de73e2c4c1..aac9ac4501 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -97,28 +97,30 @@ where } pub(crate) fn remove(&self, id: &SO::Id) -> Result<(), Error> { - let removed = self.objects.lock().unwrap().remove(id).is_some(); - if removed { - let store_key = id.encode_to_hex_str(); - KVStoreSync::remove( - &*self.kv_store, + let mut locked_objects = self.objects.lock().unwrap(); + if !locked_objects.contains_key(id) { + return Ok(()); + } + let store_key = id.encode_to_hex_str(); + KVStoreSync::remove( + &*self.kv_store, + &self.primary_namespace, + &self.secondary_namespace, + &store_key, + false, + ) + .map_err(|e| { + log_error!( + self.logger, + "Removing object data for key {}/{}/{} failed due to: {}", &self.primary_namespace, &self.secondary_namespace, - &store_key, - false, - ) - .map_err(|e| { - log_error!( - self.logger, - "Removing object data for key {}/{}/{} failed due to: {}", - &self.primary_namespace, - &self.secondary_namespace, - store_key, - e - ); - Error::PersistenceFailed - })?; - } + store_key, + e + ); + Error::PersistenceFailed + })?; + locked_objects.remove(id); Ok(()) } @@ -173,13 +175,95 @@ where #[cfg(test)] mod tests { + use std::future::Future; + use std::pin::Pin; + use std::sync::atomic::{AtomicBool, Ordering}; + use lightning::impl_writeable_tlv_based; + use lightning::io; + use lightning::util::persist::KVStore; use lightning::util::test_utils::{TestLogger, TestStore}; use super::*; use crate::hex_utils; use crate::io::test_utils::InMemoryStore; + struct FailNextRemoveStore { + inner: InMemoryStore, + fail_next_remove: AtomicBool, + } + + impl FailNextRemoveStore { + fn new() -> Self { + Self { inner: InMemoryStore::new(), fail_next_remove: AtomicBool::new(true) } + } + + fn remove_result(&self) -> io::Result<()> { + if self.fail_next_remove.swap(false, Ordering::Relaxed) { + Err(io::Error::new(io::ErrorKind::Other, "Injected remove failure")) + } else { + Ok(()) + } + } + } + + impl KVStore for FailNextRemoveStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Pin>> + Send + 'static>> { + KVStore::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Pin> + Send + 'static>> { + KVStore::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Pin> + Send + 'static>> { + let result = self.remove_result(); + if result.is_err() { + return Box::pin(async move { result }); + } + KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Pin>> + Send + 'static>> { + KVStore::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + impl KVStoreSync for FailNextRemoveStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + KVStoreSync::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> io::Result<()> { + KVStoreSync::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> io::Result<()> { + self.remove_result()?; + KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> io::Result> { + KVStoreSync::list(&self.inner, primary_namespace, secondary_namespace) + } + } + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] struct TestObjectId { id: [u8; 4], @@ -324,4 +408,44 @@ mod tests { assert_eq!(None, data_store.get(&new_id)); assert_eq!(Err(Error::PersistenceFailed), data_store.insert_or_update(new_object)); } + + #[test] + fn remove_remains_retryable_after_persistence_failure() { + let concrete_store = Arc::new(FailNextRemoveStore::new()); + let store: Arc = concrete_store.clone(); + let logger = Arc::new(TestLogger::new()); + let primary_namespace = "datastore_remove_retry_primary".to_string(); + let secondary_namespace = "datastore_remove_retry_secondary".to_string(); + let data_store = DataStore::new( + Vec::new(), + primary_namespace.clone(), + secondary_namespace.clone(), + store, + logger, + ); + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [1u8; 3] }; + let store_key = id.encode_to_hex_str(); + data_store.insert(object).unwrap(); + + assert_eq!(data_store.remove(&id), Err(Error::PersistenceFailed)); + assert_eq!(data_store.get(&id), Some(object)); + assert!(KVStoreSync::read( + &*concrete_store, + &primary_namespace, + &secondary_namespace, + &store_key + ) + .is_ok()); + + data_store.remove(&id).unwrap(); + assert_eq!(data_store.get(&id), None); + assert!(KVStoreSync::read( + &*concrete_store, + &primary_namespace, + &secondary_namespace, + &store_key + ) + .is_err()); + } } diff --git a/src/error.rs b/src/error.rs index c285162ca6..42f2991213 100644 --- a/src/error.rs +++ b/src/error.rs @@ -156,6 +156,28 @@ pub enum Error { OnchainWalletAccountNotRegistered, /// The seed bytes or a seed-derived wallet account are invalid. InvalidSeedBytes, + /// The configured chain backend rejected an on-chain transaction. + OnchainTxBroadcastRejected { + /// The rejected transaction ID. + txid: bitcoin::Txid, + }, + /// Broadcasting could not complete safely; the transaction remains persisted for reconciliation + /// or exact rebroadcast and a fresh transaction must not be created for the same payment intent. + OnchainTxBroadcastFailed { + /// The transaction ID requiring reconciliation or exact rebroadcast. + txid: bitcoin::Txid, + }, + /// Broadcasting timed out after dispatch; backend acceptance is unknown and the transaction + /// remains persisted for reconciliation or exact rebroadcast. + OnchainTxBroadcastTimeout { + /// The transaction ID requiring reconciliation or exact rebroadcast. + txid: bitcoin::Txid, + }, + /// The on-chain transaction was not handed to the configured backend. + OnchainTxBroadcastNotDispatched { + /// The transaction ID that was not dispatched. + txid: bitcoin::Txid, + }, } impl fmt::Display for Error { @@ -264,6 +286,21 @@ impl fmt::Display for Error { Self::InvalidSeedBytes => { write!(f, "The seed bytes or seed-derived wallet account are invalid.") }, + Self::OnchainTxBroadcastRejected { txid } => { + write!( + f, + "On-chain transaction {txid} was rejected by the configured chain backend." + ) + }, + Self::OnchainTxBroadcastFailed { txid } => { + write!(f, "On-chain transaction {txid} requires reconciliation after a broadcast or persistence failure.") + }, + Self::OnchainTxBroadcastTimeout { txid } => { + write!(f, "On-chain transaction {txid} has unknown broadcast acceptance after a backend timeout.") + }, + Self::OnchainTxBroadcastNotDispatched { txid } => { + write!(f, "On-chain transaction {txid} was not dispatched to the chain backend.") + }, } } } diff --git a/src/ffi/types.rs b/src/ffi/types.rs index c2d7074f0a..e8cd975d6a 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -59,7 +59,7 @@ pub use crate::payment::store::{ }; pub use crate::payment::QrPaymentResult; #[allow(unused_imports)] -pub use crate::payment::{AddressInfo, KeychainKind}; +pub use crate::payment::{AddressInfo, KeychainKind, PendingBroadcastInfo}; pub use crate::types::SpendableUtxo; use crate::{hex_utils, SocketAddress, UniffiCustomTypeConverter, UserChannelId}; diff --git a/src/io/mod.rs b/src/io/mod.rs index 611acc8362..6010fe6621 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -28,6 +28,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers"; pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// Explicit on-chain broadcast intents are persisted under this prefix. +pub(crate) const ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE: &str = "onchain_broadcast_intents"; +pub(crate) const ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/lib.rs b/src/lib.rs index 5b51005963..e3a2e19f28 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, PendingBroadcastInfo}; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, UnifiedQrPayment, @@ -356,6 +356,7 @@ impl Node { // Set event queue for onchain event emission self.chain_source.set_event_queue(Arc::clone(&self.event_queue)); + self.tx_broadcaster.resume_explicit_broadcasts(); self.spawn_chain_sync_task(); @@ -827,6 +828,7 @@ impl Node { self.background_processor_generation.fetch_add(1, Ordering::AcqRel); log_info!(self.logger, "Shutting down LDK Node with node ID {}...", self.node_id()); + self.tx_broadcaster.pause_explicit_broadcasts(); // Prevent all task groups from accepting work that could outlive this shutdown generation. self.runtime.close_task_admission(); @@ -851,6 +853,7 @@ impl Node { // Cancel cancellable background tasks self.runtime.abort_cancellable_background_tasks(); + self.runtime.block_on(self.tx_broadcaster.drain_explicit_broadcasts()); // Disconnect all peers. self.peer_manager.disconnect_all_peers(); @@ -1112,6 +1115,8 @@ impl Node { pub fn onchain_payment(&self) -> OnchainPayment { OnchainPayment::new( Arc::clone(&self.wallet), + Arc::clone(&self.tx_broadcaster), + self.runtime.control(), Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), @@ -1124,6 +1129,8 @@ impl Node { pub fn onchain_payment(&self) -> Arc { Arc::new(OnchainPayment::new( Arc::clone(&self.wallet), + Arc::clone(&self.tx_broadcaster), + self.runtime.control(), Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), @@ -2471,11 +2478,14 @@ mod tests { use std::future::Future; use std::pin::Pin; use std::str::FromStr; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; - use bitcoin::Network; + use bitcoin::absolute::LockTime; + use bitcoin::hashes::Hash; + use bitcoin::transaction::Version; + use bitcoin::{Network, Transaction, Txid}; use lightning::io; use lightning::util::persist::{KVStore, KVStoreSync}; @@ -2487,16 +2497,31 @@ mod tests { struct FailNextWriteStore { inner: InMemoryStore, fail_next_write: AtomicBool, + broadcast_intent_removes: AtomicUsize, } impl FailNextWriteStore { fn new() -> Self { - Self { inner: InMemoryStore::new(), fail_next_write: AtomicBool::new(false) } + Self { + inner: InMemoryStore::new(), + fail_next_write: AtomicBool::new(false), + broadcast_intent_removes: AtomicUsize::new(0), + } } fn fail_next_write(&self) { self.fail_next_write.store(true, Ordering::Relaxed); } + + fn broadcast_intent_remove_count(&self) -> usize { + self.broadcast_intent_removes.load(Ordering::Relaxed) + } + + fn count_broadcast_intent_remove(&self, primary_namespace: &str) { + if primary_namespace == crate::io::ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE { + self.broadcast_intent_removes.fetch_add(1, Ordering::Relaxed); + } + } } impl KVStore for FailNextWriteStore { @@ -2520,6 +2545,7 @@ mod tests { fn remove( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> Pin> + 'static + Send>> { + self.count_broadcast_intent_remove(primary_namespace); KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) } @@ -2549,6 +2575,7 @@ mod tests { fn remove( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> io::Result<()> { + self.count_broadcast_intent_remove(primary_namespace); KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) } @@ -2576,6 +2603,167 @@ mod tests { } } + fn test_wallet_node(store: Arc) -> Node { + let config = Config { network: Network::Regtest, ..Config::default() }; + let mut builder = NodeBuilder::from_config(config); + builder.set_chain_source_esplora("http://127.0.0.1:1".to_string(), None); + builder.set_entropy_seed_bytes([42u8; 64]); + builder.set_log_facade_logger(); + builder.build_with_store(store).unwrap() + } + + fn test_broadcast_transaction(lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: vec![], + output: vec![], + } + } + + #[test] + fn pending_broadcast_survives_restart_with_exact_transaction_bytes() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(42); + let txid = tx.compute_txid(); + let node = test_wallet_node(Arc::clone(&store)); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), Some(tx.clone())); + drop(node); + + let restarted_node = test_wallet_node(store); + assert_eq!(restarted_node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(restarted_node.wallet.recover_pending_broadcast(&txid).unwrap(), Some(tx)); + } + + #[test] + fn backend_mempool_eviction_preserves_unresolved_broadcast() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(43); + let txid = tx.compute_txid(); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + + node.wallet.apply_mempool_txs(Vec::new(), vec![(txid, 1)]).unwrap(); + + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), Some(tx)); + } + + #[test] + fn backend_mempool_observation_resolves_broadcast_intent() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(44); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + + node.wallet.apply_mempool_txs(vec![(tx, 1)], Vec::new()).unwrap(); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + } + + #[test] + fn backend_wallet_sync_observation_resolves_broadcast_intent() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(45); + let txid = tx.compute_txid(); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + let mut update = bdk_wallet::Update::default(); + update.tx_update.txs.push(Arc::new(tx)); + update.tx_update.seen_ats.insert((txid, 1)); + + node.wallet.apply_update(update).unwrap(); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + node.wallet.update_payment_store_for_all_transactions().unwrap(); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + } + + #[test] + fn electrum_cached_transaction_without_backend_evidence_preserves_broadcast_intent() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(451); + let txid = tx.compute_txid(); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + let mut update = bdk_wallet::Update::default(); + update.tx_update.txs.push(Arc::new(tx.clone())); + update.tx_update.evicted_ats.insert((txid, 1)); + + node.wallet.apply_update(update).unwrap(); + node.wallet.update_payment_store_for_all_transactions().unwrap(); + + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), Some(tx)); + } + + #[test] + fn reconciliation_removes_only_observed_pending_intents() { + let concrete_store = Arc::new(FailNextWriteStore::new()); + let store: Arc = concrete_store.clone(); + let tx = test_broadcast_transaction(452); + let txid = tx.compute_txid(); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + let mut update = bdk_wallet::Update::default(); + update.tx_update.txs.push(Arc::new(tx)); + update.tx_update.seen_ats.insert((txid, 1)); + for byte in 1..=16 { + update.tx_update.seen_ats.insert((Txid::from_byte_array([byte; 32]), 1)); + } + + node.wallet.apply_update(update).unwrap(); + node.wallet.update_payment_store_for_all_transactions().unwrap(); + + assert_eq!(concrete_store.broadcast_intent_remove_count(), 1); + let mut unrelated_update = bdk_wallet::Update::default(); + for byte in 17..=32 { + unrelated_update.tx_update.seen_ats.insert((Txid::from_byte_array([byte; 32]), 2)); + } + node.wallet.apply_update(unrelated_update).unwrap(); + node.wallet.update_payment_store_for_all_transactions().unwrap(); + assert_eq!(concrete_store.broadcast_intent_remove_count(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn exact_rebroadcast_retains_unknown_retry_and_clears_on_acceptance() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(46); + let txid = tx.compute_txid(); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + *node.is_running.write().unwrap() = true; + + let payment = node.onchain_payment(); + let first_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let first_request = receivers.recv().await.unwrap(); + assert_eq!(first_request.package, vec![tx.clone()]); + first_request + .result_sender + .unwrap() + .send(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)) + .unwrap(); + drop(receivers); + assert_eq!(first_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + + let payment = node.onchain_payment(); + let second_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let second_request = receivers.recv().await.unwrap(); + assert_eq!(second_request.package, vec![tx]); + second_request.result_sender.unwrap().send(Ok(())).unwrap(); + drop(receivers); + assert_eq!(second_call.await.unwrap(), Ok(txid)); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + *node.is_running.write().unwrap() = false; + } + #[test] fn background_processor_failure_marks_node_unhealthy() { let config = Config { network: Network::Regtest, ..Config::default() }; diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ba59aea06a..7034615c6c 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, PendingBroadcastInfo}; 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..d5c25265a6 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -14,8 +14,10 @@ use bitcoin::{Address, Txid}; use crate::config::{AddressType, Config, OnchainWalletAccount}; use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; -use crate::logger::{log_info, LdkLogger, Logger}; -use crate::types::{ChannelManager, SpendableUtxo, Wallet}; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::runtime::RuntimeControl; +use crate::tx_broadcaster::{ExplicitBroadcastAdmission, TxBroadcastError}; +use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; #[cfg(not(feature = "uniffi"))] @@ -67,6 +69,20 @@ impl From for bdk_wallet::KeychainKind { } } +/// An explicit on-chain broadcast whose backend acceptance is still unresolved. +/// +/// `txid` is the active transaction that [`OnchainPayment::rebroadcast_transaction`] and +/// [`OnchainPayment::abandon_pending_broadcast`] currently target. `lineage` is the complete +/// replacement history from the original spend through that active transaction and must be +/// independently reconciled as absent before abandonment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingBroadcastInfo { + /// Active transaction ID currently awaiting backend reconciliation. + pub txid: Txid, + /// Complete RBF lineage from the original spend through [`Self::txid`]. + pub lineage: Vec, +} + /// Metadata for an address derived by the on-chain wallet. /// /// The `index` is the BIP32 child index within `keychain`. For receive addresses generated by @@ -98,6 +114,8 @@ impl From for AddressInfo { /// [`Node::onchain_payment`]: crate::Node::onchain_payment pub struct OnchainPayment { wallet: Arc, + tx_broadcaster: Arc, + runtime: Arc, channel_manager: Arc, config: Arc, is_running: Arc>, @@ -106,10 +124,67 @@ pub struct OnchainPayment { impl OnchainPayment { pub(crate) fn new( - wallet: Arc, channel_manager: Arc, config: Arc, - is_running: Arc>, logger: Arc, + wallet: Arc, tx_broadcaster: Arc, runtime: Arc, + channel_manager: Arc, config: Arc, is_running: Arc>, + logger: Arc, ) -> Self { - Self { wallet, channel_manager, config, is_running, logger } + Self { wallet, tx_broadcaster, runtime, channel_manager, config, is_running, logger } + } + + fn begin_explicit_broadcast(&self) -> Result { + self.tx_broadcaster.begin_explicit_broadcast().map_err(|_| Error::NotRunning) + } + + fn dispatch_prepared_transaction( + &self, admission: ExplicitBroadcastAdmission, tx: bitcoin::Transaction, + ) -> Result { + let txid = tx.compute_txid(); + self.wallet.prepare_pending_broadcast(&tx)?; + match self + .runtime + .block_on(self.tx_broadcaster.broadcast_transaction(admission, tx.clone())) + { + Ok(()) => { + if let Err(e) = self.wallet.clear_broadcast_intent(&txid) { + log_error!( + self.logger, + "Failed to clear accepted broadcast intent {}: {}", + txid, + e + ); + } + self.wallet.publish_locally_applied_unconfirmed(txid); + Ok(txid) + }, + Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { + if self.wallet.abandon_broadcast_intent(&tx).is_err() { + return Err(Error::OnchainTxBroadcastFailed { txid }); + } + Err(Self::initial_broadcast_error(error, txid)) + }, + Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { + self.wallet.publish_locally_applied_unconfirmed(txid); + Err(Self::initial_broadcast_error(error, txid)) + }, + } + } + + fn initial_broadcast_error(error: TxBroadcastError, txid: Txid) -> Error { + match error { + TxBroadcastError::Rejected => Error::OnchainTxBroadcastRejected { txid }, + TxBroadcastError::NotDispatched => Error::OnchainTxBroadcastNotDispatched { txid }, + TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed { txid }, + TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout { txid }, + } + } + + fn rebroadcast_error(error: TxBroadcastError, txid: Txid) -> Error { + match error { + TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout { txid }, + TxBroadcastError::Rejected + | TxBroadcastError::NotDispatched + | TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed { txid }, + } } /// Retrieve a new on-chain/funding address. @@ -430,7 +505,6 @@ impl OnchainPayment { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } - let cur_anchor_reserve_sats = crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); @@ -538,6 +612,15 @@ 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. /// + /// Returns the transaction ID only after the configured backend accepts the transaction. + /// The signed transaction is persisted before dispatch. Broadcast errors carry its transaction + /// ID. If backend acceptance is unknown, the transaction remains available through + /// [`Self::list_pending_broadcasts`] for reconciliation and exact-transaction retry with + /// [`Self::rebroadcast_transaction`]. Callers must not create a new transaction for the same + /// payment intent after [`Error::OnchainTxBroadcastFailed`] or + /// [`Error::OnchainTxBroadcastTimeout`]. [`Error::OnchainTxBroadcastNotDispatched`] guarantees + /// that the backend was not invoked and cleanup completed, permitting a fresh send. + /// /// [`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, @@ -546,6 +629,7 @@ impl OnchainPayment { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } + let admission = self.begin_explicit_broadcast()?; let cur_anchor_reserve_sats = crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); @@ -553,13 +637,14 @@ impl OnchainPayment { OnchainSendAmount::ExactRetainingReserve { amount_sats, cur_anchor_reserve_sats }; let outpoints = utxos_to_spend.map(|utxos| utxos.into_iter().map(|u| u.outpoint).collect()); let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.send_to_address( + let tx = self.wallet.create_send_to_address_transaction( address, send_amount, fee_rate_opt, outpoints, &self.channel_manager, - ) + )?; + self.dispatch_prepared_transaction(admission, tx) } /// Send an on-chain payment to the given address, draining the available funds. @@ -578,6 +663,15 @@ 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. /// + /// Returns the transaction ID only after the configured backend accepts the transaction. + /// The signed transaction is persisted before dispatch. Broadcast errors carry its transaction + /// ID. If backend acceptance is unknown, the transaction remains available through + /// [`Self::list_pending_broadcasts`] for reconciliation and exact-transaction retry with + /// [`Self::rebroadcast_transaction`]. Callers must not create a new transaction for the same + /// payment intent after [`Error::OnchainTxBroadcastFailed`] or + /// [`Error::OnchainTxBroadcastTimeout`]. [`Error::OnchainTxBroadcastNotDispatched`] guarantees + /// that the backend was not invoked and cleanup completed, permitting a fresh send. + /// /// [`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( @@ -586,6 +680,7 @@ impl OnchainPayment { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } + let admission = self.begin_explicit_broadcast()?; let send_amount = if retain_reserves { let cur_anchor_reserve_sats = @@ -596,7 +691,107 @@ impl OnchainPayment { }; let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.send_to_address(address, send_amount, fee_rate_opt, None, &self.channel_manager) + let tx = self.wallet.create_send_to_address_transaction( + address, + send_amount, + fee_rate_opt, + None, + &self.channel_manager, + )?; + self.dispatch_prepared_transaction(admission, tx) + } + + /// Rebroadcast a previously prepared on-chain transaction without creating a new spend. + /// + /// Use this after recovering an acceptance-unknown transaction ID from + /// [`Self::list_pending_broadcasts`] or a transaction-keyed broadcast error. + /// The exact persisted transaction is reused, so retrying cannot create a second payment + /// transaction. On success, the same transaction ID is returned. Any retry error preserves the + /// original unresolved intent; a retry rejection or not-dispatched outcome therefore maps to + /// [`Error::OnchainTxBroadcastFailed`] rather than permitting a fresh send. After external + /// reconciliation proves the transaction absent, [`Self::abandon_pending_broadcast`] provides + /// the explicit terminal transition that releases its inputs. + /// + /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if the + /// transaction is not available in the durable broadcast-intent store. + pub fn rebroadcast_transaction(&self, txid: &Txid) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + let admission = self.begin_explicit_broadcast()?; + + let tx = self.wallet.recover_pending_broadcast(txid)?.ok_or(Error::TransactionNotFound)?; + match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(admission, tx)) { + Ok(()) => { + if let Err(e) = self.wallet.clear_broadcast_intent(txid) { + log_error!( + self.logger, + "Failed to clear accepted rebroadcast intent {}: {}", + txid, + e + ); + } + self.wallet.publish_locally_applied_unconfirmed(*txid); + Ok(*txid) + }, + Err(error) => Err(Self::rebroadcast_error(error, *txid)), + } + } + + /// List explicit broadcasts whose backend acceptance is still unresolved. + /// + /// Entries survive process restart and ordinary mempool eviction. Each record exposes the + /// active transaction ID plus the complete RBF lineage required to reconcile before + /// [`Self::abandon_pending_broadcast`]. Callers must not create a new transaction for the same + /// payment intent while an entry remains. RBF supersession atomically changes the active + /// transaction ID while preserving predecessor transactions. An entry is removed only after a + /// conclusive initial rejection/not-dispatched result, successful explicit broadcast, backend + /// observation, or explicit reconciled abandon. + pub fn list_pending_broadcasts(&self) -> Result, Error> { + Ok(self + .wallet + .list_pending_broadcast_infos()? + .into_iter() + .map(|(txid, lineage)| PendingBroadcastInfo { txid, lineage }) + .collect()) + } + + /// Abandon an unresolved explicit broadcast after conclusive external reconciliation. + /// + /// Call this only after independently verifying that the active transaction and every member of + /// its [`PendingBroadcastInfo::lineage`] are absent from the mempool and chain and will not be + /// rebroadcast by another process. Do not create a new spend of the same inputs until that + /// independent reconciliation is complete. This releases their reserved inputs and removes + /// their pending payment records. If the active transaction is later observed, ordinary wallet + /// sync will record it again. + /// + /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if + /// `txid` is not the active transaction returned by [`Self::list_pending_broadcasts`]. + /// + /// # Safety and trust boundary + /// + /// No Rust memory-safety preconditions apply. The caller must treat the configured backend as + /// insufficient evidence on its own after an acceptance-unknown result and reconcile against an + /// independent authoritative mempool/chain source before abandoning. + /// + /// # Example + /// + /// ``` + /// # use bitcoin::Txid; + /// # use ldk_node::payment::OnchainPayment; + /// # use ldk_node::NodeError; + /// # fn abandon_reconciled( + /// # payment: &OnchainPayment, + /// # txid: &Txid, + /// # ) -> Result<(), NodeError> { + /// payment.abandon_pending_broadcast(txid) + /// # } + /// ``` + pub fn abandon_pending_broadcast(&self, txid: &Txid) -> Result<(), Error> { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + self.wallet.abandon_broadcast_intent_by_txid(txid) } /// Bumps the fee of an existing transaction using Replace-By-Fee (RBF). @@ -614,7 +809,7 @@ impl OnchainPayment { /// /// # Returns /// - /// The transaction ID of the new transaction if successful. + /// The replacement transaction ID after the configured backend accepts it. /// /// # Errors /// @@ -624,10 +819,15 @@ impl OnchainPayment { /// * [`Error::CannotRbfFundingTransaction`] - If the transaction is a channel funding transaction /// * [`Error::InvalidFeeRate`] - If the new fee rate is not higher than the original /// * [`Error::OnchainTxCreationFailed`] - If the new transaction couldn't be created + /// * [`Error::OnchainTxBroadcastRejected`] - If the backend conclusively rejects the replacement + /// * [`Error::OnchainTxBroadcastNotDispatched`] - If the replacement did not reach the backend + /// * [`Error::OnchainTxBroadcastFailed`] - If replacement acceptance is unknown + /// * [`Error::OnchainTxBroadcastTimeout`] - If replacement acceptance timed out pub fn bump_fee_by_rbf(&self, txid: &Txid, fee_rate: FeeRate) -> Result { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } + let admission = self.begin_explicit_broadcast()?; // Pass through to the wallet implementation #[cfg(not(feature = "uniffi"))] @@ -635,7 +835,36 @@ impl OnchainPayment { #[cfg(feature = "uniffi")] let fee_rate_param = *fee_rate; - self.wallet.bump_fee_by_rbf(txid, fee_rate_param, &self.channel_manager) + let replacement = + self.wallet.prepare_rbf_broadcast(txid, fee_rate_param, &self.channel_manager)?; + let replacement_txid = replacement.compute_txid(); + match self + .runtime + .block_on(self.tx_broadcaster.broadcast_transaction(admission, replacement)) + { + Ok(()) => { + if let Err(e) = self.wallet.clear_broadcast_intent(&replacement_txid) { + log_error!( + self.logger, + "Failed to clear accepted RBF broadcast intent {}: {}", + replacement_txid, + e + ); + } + self.wallet.publish_locally_applied_unconfirmed(replacement_txid); + Ok(replacement_txid) + }, + Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { + if self.wallet.reject_rbf_broadcast(&replacement_txid).is_err() { + return Err(Error::OnchainTxBroadcastFailed { txid: replacement_txid }); + } + Err(Self::initial_broadcast_error(error, replacement_txid)) + }, + Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { + self.wallet.publish_locally_applied_unconfirmed(replacement_txid); + Err(Self::initial_broadcast_error(error, replacement_txid)) + }, + } } /// Accelerates confirmation of a transaction using Child-Pays-For-Parent (CPFP). @@ -725,3 +954,281 @@ impl OnchainPayment { } } } + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::pin::Pin; + use std::sync::{Arc, Condvar, Mutex}; + + use bitcoin::absolute::LockTime; + use bitcoin::hashes::Hash; + use bitcoin::transaction::Version; + use bitcoin::{Network, Transaction, Txid}; + use lightning::io; + use lightning::util::persist::{KVStore, KVStoreSync}; + + use super::OnchainPayment; + use crate::builder::NodeBuilder; + use crate::config::Config; + use crate::error::Error; + use crate::io::test_utils::InMemoryStore; + use crate::tx_broadcaster::TxBroadcastError; + use crate::types::DynStore; + use crate::Node; + + #[derive(Default)] + struct BroadcastWriteState { + armed: bool, + blocked: bool, + released: bool, + } + + struct BlockingBroadcastIntentStore { + inner: InMemoryStore, + state: Mutex, + state_changed: Condvar, + } + + impl BlockingBroadcastIntentStore { + fn new() -> Self { + Self { + inner: InMemoryStore::new(), + state: Mutex::new(BroadcastWriteState::default()), + state_changed: Condvar::new(), + } + } + + fn block_next_broadcast_intent_write(&self) { + let mut state = self.state.lock().unwrap(); + *state = BroadcastWriteState { armed: true, blocked: false, released: false }; + } + + fn wait_until_broadcast_intent_write_is_blocked(&self) { + let mut state = self.state.lock().unwrap(); + while !state.blocked { + state = self.state_changed.wait(state).unwrap(); + } + } + + fn release_broadcast_intent_write(&self) { + let mut state = self.state.lock().unwrap(); + state.released = true; + self.state_changed.notify_all(); + } + + fn maybe_block_broadcast_intent_write(&self, primary_namespace: &str) { + if primary_namespace != crate::io::ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE { + return; + } + let mut state = self.state.lock().unwrap(); + if !state.armed { + return; + } + state.blocked = true; + self.state_changed.notify_all(); + while !state.released { + state = self.state_changed.wait(state).unwrap(); + } + state.armed = false; + } + } + + impl KVStore for BlockingBroadcastIntentStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Pin, io::Error>> + 'static + Send>> { + KVStore::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Pin> + 'static + Send>> { + KVStore::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Pin> + 'static + Send>> { + KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Pin, io::Error>> + 'static + Send>> { + KVStore::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + impl KVStoreSync for BlockingBroadcastIntentStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + KVStoreSync::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> io::Result<()> { + self.maybe_block_broadcast_intent_write(primary_namespace); + KVStoreSync::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> io::Result<()> { + KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> io::Result> { + KVStoreSync::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + fn test_node(store: Arc) -> Node { + let config = Config { network: Network::Regtest, ..Config::default() }; + let mut builder = NodeBuilder::from_config(config); + builder.set_chain_source_esplora("http://127.0.0.1:1".to_string(), None); + builder.set_entropy_seed_bytes([43u8; 64]); + builder.set_log_facade_logger(); + builder.build_with_store(store).unwrap() + } + + fn test_transaction() -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(47), + input: vec![], + output: vec![], + } + } + + #[test] + fn rebroadcast_errors_preserve_the_original_unknown_outcome() { + let txid = Txid::all_zeros(); + for error in + [TxBroadcastError::Rejected, TxBroadcastError::NotDispatched, TxBroadcastError::Failed] + { + assert_eq!( + OnchainPayment::rebroadcast_error(error, txid), + Error::OnchainTxBroadcastFailed { txid } + ); + } + assert_eq!( + OnchainPayment::rebroadcast_error(TxBroadcastError::Timeout, txid), + Error::OnchainTxBroadcastTimeout { txid } + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_send_survives_eviction_restart_and_retry_until_accepted() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let node = test_node(Arc::clone(&store)); + let payment = node.onchain_payment(); + let send_tx = tx.clone(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let initial_call = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, send_tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let initial_request = receivers.recv().await.unwrap(); + assert_eq!(initial_request.package, vec![tx.clone()]); + initial_request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + drop(receivers); + assert_eq!(initial_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + node.wallet.apply_mempool_txs(Vec::new(), vec![(txid, 1)]).unwrap(); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + drop(node); + + let restarted_node = test_node(store); + assert_eq!(restarted_node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + *restarted_node.is_running.write().unwrap() = true; + + let payment = restarted_node.onchain_payment(); + let retry_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = restarted_node.tx_broadcaster.get_broadcast_queue_receivers().await; + let retry_request = receivers.recv().await.unwrap(); + assert_eq!(retry_request.package, vec![tx.clone()]); + retry_request.result_sender.unwrap().send(Err(TxBroadcastError::NotDispatched)).unwrap(); + drop(receivers); + assert_eq!(retry_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(restarted_node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + + let payment = restarted_node.onchain_payment(); + let accepted_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = restarted_node.tx_broadcaster.get_broadcast_queue_receivers().await; + let accepted_request = receivers.recv().await.unwrap(); + assert_eq!(accepted_request.package, vec![tx]); + accepted_request.result_sender.unwrap().send(Ok(())).unwrap(); + drop(receivers); + assert_eq!(accepted_call.await.unwrap(), Ok(txid)); + assert!(restarted_node.wallet.list_pending_broadcasts().unwrap().is_empty()); + *restarted_node.is_running.write().unwrap() = false; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn retry_rejection_can_be_conclusively_abandoned() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let node = test_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + *node.is_running.write().unwrap() = true; + + let payment = node.onchain_payment(); + let retry = tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); + drop(receivers); + + assert_eq!(retry.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + node.onchain_payment().abandon_pending_broadcast(&txid).unwrap(); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), None); + *node.is_running.write().unwrap() = false; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pre_stop_admission_cannot_enqueue_after_restart_when_prepare_stalls() { + let concrete_store = Arc::new(BlockingBroadcastIntentStore::new()); + let store: Arc = concrete_store.clone(); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let node = test_node(store); + *node.is_running.write().unwrap() = true; + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + concrete_store.block_next_broadcast_intent_write(); + + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, tx) + }); + let blocking_store = Arc::clone(&concrete_store); + tokio::task::spawn_blocking(move || { + blocking_store.wait_until_broadcast_intent_write_is_blocked() + }) + .await + .unwrap(); + + node.tx_broadcaster.pause_explicit_broadcasts(); + node.tx_broadcaster.drain_explicit_broadcasts().await; + node.tx_broadcaster.resume_explicit_broadcasts(); + concrete_store.release_broadcast_intent_write(); + + assert_eq!(dispatch.await.unwrap(), Err(Error::OnchainTxBroadcastNotDispatched { txid })); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + assert!(tokio::time::timeout(std::time::Duration::from_millis(20), receivers.recv()) + .await + .is_err()); + *node.is_running.write().unwrap() = false; + } +} diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 12a1fe650c..e553b0652d 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,37 +6,304 @@ // accordance with one or both of these licenses. use std::ops::Deref; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::Arc; +use std::time::Duration; -use bitcoin::Transaction; +use bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::BroadcasterInterface; -use tokio::sync::{mpsc, Mutex, MutexGuard}; +use tokio::sync::{mpsc, oneshot, Mutex, MutexGuard}; +use crate::config::TX_BROADCAST_TIMEOUT_SECS; use crate::logger::{log_error, LdkLogger}; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +const EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE: usize = 50; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TxBroadcastError { + Rejected, + NotDispatched, + Failed, + Timeout, +} + +pub(crate) fn classify_rpc_broadcast_error( + code: Option, message: &str, +) -> Result<(), TxBroadcastError> { + let normalized_message = message.to_ascii_lowercase(); + let compact_message = normalized_message + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let contains_code = |candidate| { + code == Some(candidate) + || compact_message.contains(&format!("\"code\":{}", candidate)) + || normalized_message.contains(&format!("rpc error {}", candidate)) + }; + + if contains_code(-27) + || [ + "already in block chain", + "already in blockchain", + "already in mempool", + "transaction already known", + "txn-already-known", + ] + .iter() + .any(|marker| normalized_message.contains(marker)) + { + return Ok(()); + } + + if contains_code(-25) + || contains_code(-26) + || [ + "bad-txns-", + "dust", + "insufficient fee", + "mandatory-script-verify-flag-failed", + "mempool min fee not met", + "min relay fee not met", + "missing inputs", + "non-bip68-final", + "non-final", + "non-mandatory-script-verify-flag", + "txn-mempool-conflict", + ] + .iter() + .any(|marker| normalized_message.contains(marker)) + { + return Err(TxBroadcastError::Rejected); + } + + Err(TxBroadcastError::Failed) +} + +pub(crate) fn validate_broadcast_txid( + expected_txid: Txid, returned_txid: Txid, +) -> Result<(), TxBroadcastError> { + if returned_txid == expected_txid { + Ok(()) + } else { + Err(TxBroadcastError::Failed) + } +} + +const EXPLICIT_BROADCAST_QUEUED: u8 = 0; +const EXPLICIT_BROADCAST_CLAIMED: u8 = 1; +const EXPLICIT_BROADCAST_CANCELLED: u8 = 2; + +struct ExplicitBroadcastClaim { + state: AtomicU8, +} + +impl ExplicitBroadcastClaim { + fn new() -> Self { + Self { state: AtomicU8::new(EXPLICIT_BROADCAST_QUEUED) } + } + + fn try_claim(&self) -> bool { + self.state + .compare_exchange( + EXPLICIT_BROADCAST_QUEUED, + EXPLICIT_BROADCAST_CLAIMED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn cancel_if_queued(&self) -> bool { + self.state + .compare_exchange( + EXPLICIT_BROADCAST_QUEUED, + EXPLICIT_BROADCAST_CANCELLED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } +} + +struct CancelExplicitBroadcastOnDrop { + claim: Arc, +} + +impl Drop for CancelExplicitBroadcastOnDrop { + fn drop(&mut self) { + self.claim.cancel_if_queued(); + } +} + +pub(crate) struct BroadcastRequest { + pub(crate) package: Vec, + pub(crate) result_sender: Option>>, + explicit_claim: Option>, +} + +impl BroadcastRequest { + fn explicit( + package: Vec, result_sender: oneshot::Sender>, + ) -> (Self, Arc) { + let explicit_claim = Arc::new(ExplicitBroadcastClaim::new()); + ( + Self { + package, + result_sender: Some(result_sender), + explicit_claim: Some(Arc::clone(&explicit_claim)), + }, + explicit_claim, + ) + } + + fn ldk(package: Vec) -> Self { + Self { package, result_sender: None, explicit_claim: None } + } + + fn try_claim(&self) -> bool { + self.explicit_claim.as_ref().map_or(true, |claim| claim.try_claim()) + } +} + +/// Separate receivers for safety-critical LDK traffic and bounded explicit user sends. +pub(crate) struct BroadcastQueueReceivers { + ldk_receiver: mpsc::UnboundedReceiver, + explicit_receiver: mpsc::Receiver, +} + +impl BroadcastQueueReceivers { + /// Returns the next request, prioritizing LDK traffic when both queues are ready. + pub(crate) async fn recv(&mut self) -> Option { + loop { + let request = tokio::select! { + biased; + request = self.ldk_receiver.recv() => request, + request = self.explicit_receiver.recv() => request, + }; + match request { + Some(request) if request.try_claim() => return Some(request), + Some(_) => continue, + None => return None, + } + } + } + + /// Completes queued explicit requests without dispatching them when the worker stops. + pub(crate) fn fail_queued_explicit_requests(&mut self) { + while let Ok(request) = self.explicit_receiver.try_recv() { + if !request.try_claim() { + continue; + } + if let Some(result_sender) = request.result_sender { + let _ = result_sender.send(Err(TxBroadcastError::NotDispatched)); + } + } + } +} pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + ldk_sender: mpsc::UnboundedSender, + explicit_sender: mpsc::Sender, + queue_receivers: Mutex, + explicit_broadcast_run: std::sync::Mutex>>, logger: L, } +struct ExplicitBroadcastRun; + +#[derive(Clone)] +pub(crate) struct ExplicitBroadcastAdmission(Arc); + impl TransactionBroadcaster where L::Target: LdkLogger, { pub(crate) fn new(logger: L) -> Self { - let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { queue_sender, queue_receiver: Mutex::new(queue_receiver), logger } + let (ldk_sender, ldk_receiver) = mpsc::unbounded_channel(); + let (explicit_sender, explicit_receiver) = mpsc::channel(EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE); + let queue_receivers = + Mutex::new(BroadcastQueueReceivers { ldk_receiver, explicit_receiver }); + Self { + ldk_sender, + explicit_sender, + queue_receivers, + explicit_broadcast_run: std::sync::Mutex::new(Some(Arc::new(ExplicitBroadcastRun))), + logger, + } + } + + /// Starts a new explicit-broadcast run after the prior queue was drained. + pub(crate) fn resume_explicit_broadcasts(&self) { + *self.explicit_broadcast_run.lock().unwrap() = Some(Arc::new(ExplicitBroadcastRun)); + } + + /// Invalidates every admission captured for the current run. + pub(crate) fn pause_explicit_broadcasts(&self) { + *self.explicit_broadcast_run.lock().unwrap() = None; + } + + /// Captures the current run before transaction creation or durable preparation begins. + pub(crate) fn begin_explicit_broadcast( + &self, + ) -> Result { + self.explicit_broadcast_run + .lock() + .unwrap() + .as_ref() + .cloned() + .map(ExplicitBroadcastAdmission) + .ok_or(TxBroadcastError::NotDispatched) + } + + /// Completes every queued explicit request after new enqueue operations have been fenced. + pub(crate) async fn drain_explicit_broadcasts(&self) { + let mut receivers = self.queue_receivers.lock().await; + receivers.fail_queued_explicit_requests(); } - pub(crate) async fn get_broadcast_queue( + pub(crate) async fn get_broadcast_queue_receivers( &self, - ) -> MutexGuard<'_, mpsc::Receiver>> { - self.queue_receiver.lock().await + ) -> MutexGuard<'_, BroadcastQueueReceivers> { + self.queue_receivers.lock().await + } + + pub(crate) async fn broadcast_transaction( + &self, admission: ExplicitBroadcastAdmission, tx: Transaction, + ) -> Result<(), TxBroadcastError> { + self.broadcast_transaction_with_timeout( + admission, + tx, + Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), + ) + .await + } + + async fn broadcast_transaction_with_timeout( + &self, admission: ExplicitBroadcastAdmission, tx: Transaction, timeout: Duration, + ) -> Result<(), TxBroadcastError> { + let (result_sender, result_receiver) = oneshot::channel(); + let (request, explicit_claim) = BroadcastRequest::explicit(vec![tx], result_sender); + { + let active_run = self.explicit_broadcast_run.lock().unwrap(); + if !active_run.as_ref().is_some_and(|active_run| Arc::ptr_eq(active_run, &admission.0)) + { + return Err(TxBroadcastError::NotDispatched); + } + self.explicit_sender.try_send(request).map_err(|_| TxBroadcastError::NotDispatched)?; + } + let _cancel_on_drop = CancelExplicitBroadcastOnDrop { claim: Arc::clone(&explicit_claim) }; + let mut result_receiver = result_receiver; + let receiver_result = match tokio::time::timeout(timeout, &mut result_receiver).await { + Ok(result) => result, + Err(_) if explicit_claim.cancel_if_queued() => { + return Err(TxBroadcastError::NotDispatched) + }, + Err(_) => result_receiver.await, + }; + receiver_result.map_err(|_| TxBroadcastError::Failed)? } } @@ -46,8 +313,295 @@ where { fn broadcast_transactions(&self, txs: &[&Transaction]) { let package = txs.iter().map(|&t| t.clone()).collect::>(); - self.queue_sender.try_send(package).unwrap_or_else(|e| { + let request = BroadcastRequest::ldk(package); + self.ldk_sender.send(request).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::Transaction; + use lightning::chain::chaininterface::BroadcasterInterface; + use lightning::util::test_utils::TestLogger; + + use super::{ + classify_rpc_broadcast_error, BroadcastRequest, TransactionBroadcaster, TxBroadcastError, + EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE, + }; + + fn test_transaction() -> Transaction { + test_transaction_with_lock_time(0) + } + + fn test_transaction_with_lock_time(lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: vec![], + output: vec![], + } + } + + #[test] + fn rpc_broadcast_errors_distinguish_known_rejections_and_ambiguous_failures() { + assert_eq!( + classify_rpc_broadcast_error(Some(-27), "Transaction already in block chain"), + Ok(()) + ); + assert_eq!( + classify_rpc_broadcast_error(None, r#"sendrawtransaction: {"code": -26}"#), + Err(TxBroadcastError::Rejected) + ); + assert_eq!( + classify_rpc_broadcast_error(None, "non-final"), + Err(TxBroadcastError::Rejected) + ); + assert_eq!( + classify_rpc_broadcast_error(Some(-28), "Loading block index"), + Err(TxBroadcastError::Failed) + ); + } + + #[tokio::test] + async fn explicit_broadcast_returns_after_backend_acceptance() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn explicit_broadcast_propagates_backend_rejection() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Err(TxBroadcastError::Rejected)); + } + + #[tokio::test] + async fn explicit_broadcast_propagates_backend_failure() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Err(TxBroadcastError::Failed)); + } + + #[tokio::test] + async fn claimed_explicit_broadcast_waits_for_backend_result_after_queue_timeout() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_millis(10), + ); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn queued_explicit_broadcast_is_cancelled_before_backend_claim() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let cancelled_result = broadcaster + .broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_millis(10), + ) + .await; + assert_eq!(cancelled_result, Err(TxBroadcastError::NotDispatched)); + + let live_tx = test_transaction_with_lock_time(1); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + live_tx.clone(), + Duration::from_secs(1), + ); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![live_tx]); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn dropped_explicit_broadcast_future_cancels_queued_request() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let cancelled_broadcaster = Arc::clone(&broadcaster); + let cancelled_task = tokio::spawn(async move { + let admission = cancelled_broadcaster.begin_explicit_broadcast().unwrap(); + cancelled_broadcaster + .broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_secs(1), + ) + .await + }); + tokio::task::yield_now().await; + assert_eq!(broadcaster.explicit_sender.capacity(), EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE - 1); + cancelled_task.abort(); + assert!(cancelled_task.await.unwrap_err().is_cancelled()); + + let live_tx = test_transaction_with_lock_time(1); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + live_tx.clone(), + Duration::from_secs(1), + ); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![live_tx]); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn stopping_worker_fails_queued_explicit_broadcast_without_dispatch() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_secs(1), + ); + let stop_fut = async { + tokio::task::yield_now().await; + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + receivers.fail_queued_explicit_requests(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, stop_fut); + assert_eq!(result, Err(TxBroadcastError::NotDispatched)); + } + + #[tokio::test] + async fn stopped_queue_rejects_new_requests_and_does_not_replay_them_after_restart() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let stale_admission = broadcaster.begin_explicit_broadcast().unwrap(); + broadcaster.pause_explicit_broadcasts(); + broadcaster.drain_explicit_broadcasts().await; + + assert_eq!( + broadcaster.begin_explicit_broadcast().err(), + Some(TxBroadcastError::NotDispatched) + ); + + broadcaster.resume_explicit_broadcasts(); + assert_eq!( + broadcaster + .broadcast_transaction_with_timeout( + stale_admission, + test_transaction(), + Duration::from_secs(1), + ) + .await, + Err(TxBroadcastError::NotDispatched) + ); + + let live_tx = test_transaction_with_lock_time(1); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + live_tx.clone(), + Duration::from_secs(1), + ); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![live_tx]); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn ldk_broadcast_remains_fire_and_forget() { + let broadcaster = TransactionBroadcaster::new(Arc::new(TestLogger::new())); + let tx = test_transaction(); + broadcaster.broadcast_transactions(&[&tx]); + + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![tx]); + assert!(request.result_sender.is_none()); + } + + #[tokio::test] + async fn ldk_broadcast_is_not_dropped_when_explicit_queue_is_saturated() { + let broadcaster = TransactionBroadcaster::new(Arc::new(TestLogger::new())); + for _ in 0..EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE { + let (result_sender, _result_receiver) = tokio::sync::oneshot::channel(); + let (request, _claim) = + BroadcastRequest::explicit(vec![test_transaction()], result_sender); + broadcaster.explicit_sender.try_send(request).unwrap(); + } + + assert_eq!( + broadcaster + .broadcast_transaction_with_timeout( + broadcaster.begin_explicit_broadcast().unwrap(), + test_transaction(), + Duration::from_secs(1), + ) + .await, + Err(TxBroadcastError::NotDispatched) + ); + + let ldk_tx = test_transaction(); + broadcaster.broadcast_transactions(&[&ldk_tx]); + + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![ldk_tx]); + assert!(request.result_sender.is_none()); + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 26fde71f80..6ae12b221d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -12,6 +12,7 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::ConfirmationBlockTime; @@ -24,6 +25,7 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::bip32::Xpriv; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; +use bitcoin::consensus::{deserialize, serialize}; use bitcoin::hashes::Hash; use bitcoin::key::XOnlyPublicKey; use bitcoin::psbt::{self, Psbt}; @@ -48,6 +50,7 @@ use lightning::sign::{ PeerStorageKey, Recipient, SignerProvider, SpendableOutputDescriptor, }; use lightning::util::message_signing; +use lightning::util::persist::KVStoreSync; use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; use zeroize::Zeroizing; @@ -57,6 +60,9 @@ use crate::config::{ }; use crate::event::{TxInput, TxOutput}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; +use crate::io::{ + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, +}; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::payment::store::ConfirmationStatus; use crate::payment::{KeychainKind, PaymentDetails, PaymentDirection, PaymentStatus}; @@ -67,6 +73,256 @@ use crate::{Error, NodeMetrics}; const DUST_LIMIT_SATS: u64 = 546; const BIP32_MAX_NORMAL_INDEX: u32 = (1 << 31) - 1; const MAX_ADDRESS_INFO_BATCH_COUNT: u32 = bdk_wallet_aggregate::MAX_ADDRESS_INFO_BATCH_COUNT; +const LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 1; +const LEGACY_RBF_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 2; +const LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 3; +const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 4; +const NO_PREDECESSOR_INDEX: u64 = u64::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BroadcastIntentState { + Pending, + Accepted, + Rejecting, + Abandoning, + Observing, + Confirming, +} + +impl BroadcastIntentState { + fn serialization_tag(self) -> u8 { + match self { + Self::Pending => 0, + Self::Accepted => 1, + Self::Rejecting => 2, + Self::Abandoning => 3, + Self::Observing => 4, + Self::Confirming => 5, + } + } + + fn from_serialization_tag(tag: u8) -> Result { + match tag { + 0 => Ok(Self::Pending), + 1 => Ok(Self::Accepted), + 2 => Ok(Self::Rejecting), + 3 => Ok(Self::Abandoning), + 4 => Ok(Self::Observing), + 5 => Ok(Self::Confirming), + _ => Err(Error::PersistenceFailed), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct BroadcastIntent { + transactions: Vec, + active_index: u32, + state: BroadcastIntentState, + // Every replacement records the active lineage member it superseded and whether that member + // still needed reconciliation. This makes rejection rollback durable even after branching. + predecessor_indexes: Vec, + predecessor_was_pending: Vec, +} + +impl BroadcastIntent { + fn new(tx: Transaction) -> Self { + Self { + transactions: vec![tx], + active_index: 0, + state: BroadcastIntentState::Pending, + predecessor_indexes: vec![NO_PREDECESSOR_INDEX], + predecessor_was_pending: vec![0], + } + } + + fn replacement( + existing: Option, original: Transaction, replacement: Transaction, + ) -> Result { + let is_new_intent = existing.is_none(); + let mut intent = existing.unwrap_or_else(|| Self::new(original)); + if is_new_intent { + // The original was already accepted. Retain it only as reconciliation evidence. + intent.state = BroadcastIntentState::Accepted; + } + intent.supersede(replacement)?; + Ok(intent) + } + + fn from_legacy( + transactions: Vec, first_pending_index: u32, + ) -> Result { + if transactions.is_empty() || first_pending_index as usize > transactions.len() { + return Err(Error::PersistenceFailed); + } + let active_index = + u32::try_from(transactions.len() - 1).map_err(|_| Error::PersistenceFailed)?; + let mut predecessor_indexes = Vec::with_capacity(transactions.len()); + let mut predecessor_was_pending = Vec::with_capacity(transactions.len()); + predecessor_indexes.push(NO_PREDECESSOR_INDEX); + predecessor_was_pending.push(0); + for index in 1..transactions.len() { + predecessor_indexes + .push(u64::try_from(index - 1).map_err(|_| Error::PersistenceFailed)?); + predecessor_was_pending.push(u8::from(index - 1 >= first_pending_index as usize)); + } + let state = if first_pending_index as usize == transactions.len() { + BroadcastIntentState::Accepted + } else { + BroadcastIntentState::Pending + }; + Ok(Self { transactions, active_index, state, predecessor_indexes, predecessor_was_pending }) + } + + fn key(&self) -> Txid { + self.transactions.first().expect("broadcast intents are non-empty").compute_txid() + } + + fn active_transaction(&self) -> &Transaction { + &self.transactions[self.active_index as usize] + } + + fn active_txid(&self) -> Txid { + self.active_transaction().compute_txid() + } + + fn has_pending_transaction(&self) -> bool { + self.state == BroadcastIntentState::Pending + } + + fn mark_accepted(&mut self, txid: Txid) -> Result<(), Error> { + self.active_index = self + .transactions + .iter() + .position(|tx| tx.compute_txid() == txid) + .and_then(|index| u32::try_from(index).ok()) + .ok_or(Error::TransactionNotFound)?; + self.state = BroadcastIntentState::Accepted; + Ok(()) + } + + fn begin_observation(&mut self, txid: Txid) -> Result<(), Error> { + self.mark_accepted(txid)?; + self.state = BroadcastIntentState::Observing; + Ok(()) + } + + fn begin_confirmation(&mut self, txid: Txid) -> Result<(), Error> { + self.mark_accepted(txid)?; + self.state = BroadcastIntentState::Confirming; + Ok(()) + } + + fn begin_rejection(&mut self) -> Result<(), Error> { + if self.state != BroadcastIntentState::Pending + || self.predecessor_indexes[self.active_index as usize] == NO_PREDECESSOR_INDEX + { + return Err(Error::TransactionNotFound); + } + self.state = BroadcastIntentState::Rejecting; + Ok(()) + } + + fn finish_rejection(&mut self) -> Result { + if self.state != BroadcastIntentState::Rejecting + || self.active_index as usize != self.transactions.len().saturating_sub(1) + { + return Err(Error::PersistenceFailed); + } + let rejected_index = self.active_index as usize; + let predecessor_index = self.predecessor_indexes[rejected_index]; + let predecessor_was_pending = self.predecessor_was_pending[rejected_index] != 0; + if predecessor_index == NO_PREDECESSOR_INDEX { + return Err(Error::PersistenceFailed); + } + let rejected_tx = self.transactions.pop().ok_or(Error::PersistenceFailed)?; + self.predecessor_indexes.pop(); + self.predecessor_was_pending.pop(); + self.active_index = + u32::try_from(predecessor_index).map_err(|_| Error::PersistenceFailed)?; + self.state = if predecessor_was_pending { + BroadcastIntentState::Pending + } else { + BroadcastIntentState::Accepted + }; + Ok(rejected_tx) + } + + fn begin_abandon(&mut self) -> Result<(), Error> { + if self.state != BroadcastIntentState::Pending { + return Err(Error::TransactionNotFound); + } + self.state = BroadcastIntentState::Abandoning; + Ok(()) + } + + fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { + if !matches!(self.state, BroadcastIntentState::Pending | BroadcastIntentState::Accepted) { + return Err(Error::WalletOperationFailed); + } + let replaces_active = replacement.input.iter().any(|replacement_input| { + self.active_transaction().input.iter().any(|active_input| { + active_input.previous_output == replacement_input.previous_output + }) + }); + if !replaces_active { + return Err(Error::OnchainTxCreationFailed); + } + let predecessor_index = u64::from(self.active_index); + let predecessor_was_pending = u8::from(self.state == BroadcastIntentState::Pending); + self.transactions.push(replacement); + self.predecessor_indexes.push(predecessor_index); + self.predecessor_was_pending.push(predecessor_was_pending); + self.active_index = + u32::try_from(self.transactions.len() - 1).map_err(|_| Error::WalletOperationFailed)?; + self.state = BroadcastIntentState::Pending; + Ok(()) + } + + fn is_valid(&self) -> bool { + let len = self.transactions.len(); + if len == 0 + || self.predecessor_indexes.len() != len + || self.predecessor_was_pending.len() != len + || self.active_index as usize >= len + || self.predecessor_indexes[0] != NO_PREDECESSOR_INDEX + || self.predecessor_was_pending[0] != 0 + { + return false; + } + if matches!( + self.state, + BroadcastIntentState::Pending + | BroadcastIntentState::Rejecting + | BroadcastIntentState::Abandoning + ) && self.active_index as usize != len - 1 + { + return false; + } + if self.state == BroadcastIntentState::Rejecting + && self.predecessor_indexes[self.active_index as usize] == NO_PREDECESSOR_INDEX + { + return false; + } + for index in 1..len { + let Ok(predecessor_index) = usize::try_from(self.predecessor_indexes[index]) else { + return false; + }; + if predecessor_index >= index || self.predecessor_was_pending[index] > 1 { + return false; + } + let replaces_predecessor = self.transactions[index].input.iter().any(|input| { + self.transactions[predecessor_index].input.iter().any(|predecessor_input| { + predecessor_input.previous_output == input.previous_output + }) + }); + if !replaces_predecessor { + return false; + } + } + true + } +} fn validate_derivation_index(index: u32) -> Result<(), Error> { if index > BIP32_MAX_NORMAL_INDEX { @@ -88,6 +344,50 @@ fn validate_derivation_range(start_index: u32, count: u32) -> Result<(), Error> validate_derivation_index(last_index) } +fn backend_observed_txids(update: &Update) -> HashSet { + update + .tx_update + .anchors + .iter() + .map(|(_, txid)| *txid) + .chain(update.tx_update.seen_ats.iter().map(|(txid, _)| *txid)) + .collect() +} + +fn backend_confirmed_txids(update: &Update) -> HashSet { + update.tx_update.anchors.iter().map(|(_, txid)| *txid).collect() +} + +fn observed_broadcast_intents( + intents: &[(Txid, BroadcastIntent)], observed_txids: &HashSet, + confirmed_txids: &HashSet, +) -> Vec<(Txid, Txid, bool)> { + intents + .iter() + .filter_map(|(key, intent)| { + if let Some(txid) = intent + .transactions + .iter() + .rev() + .map(|tx| tx.compute_txid()) + .find(|txid| confirmed_txids.contains(txid)) + { + return Some((*key, txid, true)); + } + let active_txid = intent.active_txid(); + observed_txids.contains(&active_txid).then_some((*key, active_txid, false)) + }) + .collect() +} + +fn clamp_external_timestamp(timestamp: u64, now: u64) -> u64 { + if timestamp > u32::MAX as u64 { + now + } else { + timestamp + } +} + fn map_wallet_account_error( wallet_account: OnchainWalletAccount, error: bdk_wallet_aggregate::Error, ) -> Error { @@ -135,6 +435,10 @@ pub(crate) mod ser; pub(crate) struct Wallet { inner: Mutex>, + // Serializes raw-intent persistence with wallet reservation and backend reconciliation. + broadcast_intent_lock: Mutex<()>, + // Keyed by the first transaction ID so an RBF replacement can atomically update one record. + broadcast_intents: Mutex>, // Serializes account membership, primary selection, and account reloads. operation_lock: Mutex<()>, account_generation: AtomicU64, @@ -150,6 +454,7 @@ pub(crate) struct Wallet { node_metrics: Arc>, logger: Arc, derived_account_lookahead: u32, + locally_applied_unconfirmed_txids: Mutex>, } impl Wallet { @@ -175,6 +480,8 @@ impl Wallet { let operation_lock = Mutex::new(()); Self { inner, + broadcast_intent_lock: Mutex::new(()), + broadcast_intents: Mutex::new(HashMap::new()), operation_lock, account_generation: AtomicU64::new(0), synced_derived_accounts: Mutex::new(HashSet::new()), @@ -189,6 +496,7 @@ impl Wallet { node_metrics, logger, derived_account_lookahead, + locally_applied_unconfirmed_txids: Mutex::new(Vec::new()), } } @@ -347,6 +655,12 @@ impl Wallet { pub(crate) fn apply_block_to_account( &self, account: OnchainWalletAccount, block: &bitcoin::Block, height: u32, ) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let pending_intents = self.read_all_broadcast_intents()?; + let block_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); + let resolutions = observed_broadcast_intents(&pending_intents, &block_txids, &block_txids); + self.stage_broadcast_resolutions(&resolutions)?; let mut locked = self.inner.lock().unwrap(); self.payment_store_update_pending.store(true, Ordering::Release); locked.apply_block_to(&account, block, height).map_err(|e| { @@ -363,10 +677,13 @@ impl Wallet { _ => Error::WalletOperationFailed, } })?; - Ok(()) + drop(locked); + self.resolve_broadcast_intents(resolutions) } pub(crate) fn finish_pending_sync(&self, refresh_payment_store: bool) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; let mut locked = self.inner.lock().unwrap(); locked.persist_all().map_err(|e| { log_error!(self.logger, "Failed to persist pending wallet changes: {}", e); @@ -382,6 +699,7 @@ impl Wallet { e })?; } + drop(locked); Ok(()) } @@ -768,9 +1086,32 @@ impl Wallet { pub(crate) fn apply_update( &self, update: impl Into, ) -> Result, Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let update = update.into(); + let authoritative_txids = backend_observed_txids(&update); + let confirmed_txids = backend_confirmed_txids(&update); + let pending_intents = self.read_all_broadcast_intents()?; + let resolutions = + observed_broadcast_intents(&pending_intents, &authoritative_txids, &confirmed_txids); + self.stage_broadcast_resolutions(&resolutions)?; + let observed_intent_keys = + resolutions.iter().map(|(key, _, _)| *key).collect::>(); + let unresolved_txs = pending_intents + .into_iter() + .filter(|(key, intent)| { + intent.has_pending_transaction() && !observed_intent_keys.contains(key) + }) + .map(|(_, intent)| intent) + .collect(); let mut locked_wallet = self.inner.lock().unwrap(); match locked_wallet.apply_update(update) { - Ok((events, _txids)) => Ok(events), + Ok((events, _txids)) => { + self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; + drop(locked_wallet); + self.resolve_broadcast_intents(resolutions)?; + Ok(events) + }, Err(e) => { log_error!(self.logger, "Sync failed due to chain connection error: {}", e); Err(match e { @@ -788,12 +1129,35 @@ impl Wallet { &self, wallet_account: OnchainWalletAccount, update: impl Into, ) -> Result>, Error> { let _op = self.operation_lock.lock().unwrap(); + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let update = update.into(); + let authoritative_txids = backend_observed_txids(&update); + let confirmed_txids = backend_confirmed_txids(&update); + let pending_intents = self.read_all_broadcast_intents()?; + let resolutions = + observed_broadcast_intents(&pending_intents, &authoritative_txids, &confirmed_txids); + self.stage_broadcast_resolutions(&resolutions)?; + let observed_intent_keys = + resolutions.iter().map(|(key, _, _)| *key).collect::>(); + let unresolved_txs = pending_intents + .into_iter() + .filter(|(key, intent)| { + intent.has_pending_transaction() && !observed_intent_keys.contains(key) + }) + .map(|(_, intent)| intent) + .collect(); let mut locked_wallet = self.inner.lock().unwrap(); if locked_wallet.wallet(&wallet_account).is_none() { return Ok(None); } match locked_wallet.apply_update_to_wallet(wallet_account, update) { - Ok((events, _txids)) => Ok(Some(events)), + Ok((events, _txids)) => { + self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; + drop(locked_wallet); + self.resolve_broadcast_intents(resolutions)?; + Ok(Some(events)) + }, Err(e) => { log_error!( self.logger, @@ -813,20 +1177,699 @@ impl Wallet { pub(crate) fn apply_mempool_txs( &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let unconfirmed_txs = unconfirmed_txs + .into_iter() + .map(|(tx, timestamp)| (tx, clamp_external_timestamp(timestamp, now))) + .collect::>(); + let evicted_txids = evicted_txids + .into_iter() + .map(|(txid, timestamp)| (txid, clamp_external_timestamp(timestamp, now))) + .collect::>(); + let authoritative_txids = + unconfirmed_txs.iter().map(|(tx, _)| tx.compute_txid()).collect::>(); + let pending_intents = self.read_all_broadcast_intents()?; + let resolutions = + observed_broadcast_intents(&pending_intents, &authoritative_txids, &HashSet::new()); + self.stage_broadcast_resolutions(&resolutions)?; + let observed_intent_keys = + resolutions.iter().map(|(key, _, _)| *key).collect::>(); + let unresolved_txs = pending_intents + .into_iter() + .filter(|(key, intent)| { + intent.has_pending_transaction() && !observed_intent_keys.contains(key) + }) + .map(|(_, intent)| intent) + .collect(); let mut locked_wallet = self.inner.lock().unwrap(); self.payment_store_update_pending.store(true, Ordering::Release); locked_wallet.apply_mempool_txs(unconfirmed_txs, evicted_txids).map_err(|e| { log_error!(self.logger, "Failed to apply mempool txs: {}", e); Error::PersistenceFailed })?; - self.update_payment_store(&locked_wallet) + self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + self.resolve_broadcast_intents(resolutions) + } + + /// Durably reserve and index a signed transaction before backend dispatch. + pub(crate) fn prepare_pending_broadcast(&self, tx: &Transaction) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let txid = tx.compute_txid(); + self.write_broadcast_intent(&BroadcastIntent::new(tx.clone()))?; + let mut locked_wallet = self.inner.lock().unwrap(); + let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[txid])?; + self.payment_store_update_pending.store(true, Ordering::Release); + if let Err(e) = locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) { + log_error!(self.logger, "Failed to reserve pending transaction {}: {}", txid, e); + return Err(Error::OnchainTxBroadcastFailed { txid }); + } + if let Err(e) = self.update_payment_store(&locked_wallet) { + log_error!(self.logger, "Failed to index pending transaction {}: {}", txid, e); + return Err(Error::OnchainTxBroadcastFailed { txid }); + } + self.note_locally_applied_unconfirmed(txid); + Ok(()) + } + + /// Release and forget a transaction only after a conclusive initial-send outcome. + pub(crate) fn abandon_broadcast_intent(&self, tx: &Transaction) -> Result<(), Error> { + self.abandon_broadcast_intent_by_txid(&tx.compute_txid()) + } + + /// Release every transaction in an intent after conclusive external reconciliation. + pub(crate) fn abandon_broadcast_intent_by_txid(&self, txid: &Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let (intent_key, mut intent) = + self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; + intent.begin_abandon()?; + self.write_broadcast_intent(&intent)?; + self.complete_abandon(intent_key, intent) + } + + fn complete_abandon(&self, intent_key: Txid, intent: BroadcastIntent) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Abandoning { + return Err(Error::PersistenceFailed); + } + let txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let mut locked_wallet = self.inner.lock().unwrap(); + locked_wallet.abandon_txs(&intent.transactions).map_err(|e| { + log_error!( + self.logger, + "Failed to abandon pending transaction {}: {}", + intent.active_txid(), + e + ); + Error::PersistenceFailed + })?; + drop(locked_wallet); + for intent_txid in &txids { + self.payment_store.remove(&PaymentId(intent_txid.to_byte_array()))?; + } + self.forget_locally_applied_unconfirmed(&txids); + self.remove_broadcast_intent(&intent_key) + } + + /// Recover and reserve the exact durable transaction bytes for an explicit rebroadcast. + pub(crate) fn recover_pending_broadcast( + &self, txid: &Txid, + ) -> Result, Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let Some((_, intent)) = self.find_broadcast_intent_by_active_txid(txid) else { + return Ok(None); + }; + if !intent.has_pending_transaction() { + return Ok(None); + } + let tx = intent.active_transaction().clone(); + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let mut locked_wallet = self.inner.lock().unwrap(); + match Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids) { + Ok(last_seen) => { + self.payment_store_update_pending.store(true, Ordering::Release); + if let Err(e) = + locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) + { + log_error!( + self.logger, + "Failed to restore pending transaction {}: {}", + txid, + e + ); + return Err(Error::OnchainTxBroadcastFailed { txid: *txid }); + } + if let Err(e) = self.update_payment_store(&locked_wallet) { + log_error!(self.logger, "Failed to restore payment index for {}: {}", txid, e); + return Err(Error::OnchainTxBroadcastFailed { txid: *txid }); + } + }, + Err(_) if locked_wallet.find_tx(*txid).is_some() => {}, + Err(_) => return Err(Error::OnchainTxBroadcastFailed { txid: *txid }), + } + Ok(Some(tx)) + } + + /// List transaction IDs whose backend acceptance still requires reconciliation. + #[cfg(test)] + pub(crate) fn list_pending_broadcasts(&self) -> Result, Error> { + Ok(self.list_pending_broadcast_infos()?.into_iter().map(|(txid, _)| txid).collect()) + } + + pub(crate) fn list_pending_broadcast_infos(&self) -> Result)>, Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + Ok(self + .read_all_broadcast_intents()? + .into_iter() + .filter(|(_, intent)| intent.has_pending_transaction()) + .map(|(_, intent)| { + ( + intent.active_txid(), + intent.transactions.iter().map(|tx| tx.compute_txid()).collect(), + ) + }) + .collect()) + } + + pub(crate) fn take_locally_applied_unconfirmed_txids(&self) -> Vec { + let mut stored = self.locally_applied_unconfirmed_txids.lock().unwrap(); + let mut ready = Vec::new(); + stored.retain(|(txid, publishable)| { + if *publishable { + ready.push(*txid); + false + } else { + true + } + }); + ready + } + + fn note_locally_applied_unconfirmed(&self, txid: Txid) { + self.locally_applied_unconfirmed_txids.lock().unwrap().push((txid, false)); + } + + pub(crate) fn publish_locally_applied_unconfirmed(&self, txid: Txid) { + for (stored, publishable) in + self.locally_applied_unconfirmed_txids.lock().unwrap().iter_mut() + { + if *stored == txid { + *publishable = true; + } + } + } + + fn forget_locally_applied_unconfirmed(&self, txids: &[Txid]) { + self.locally_applied_unconfirmed_txids + .lock() + .unwrap() + .retain(|(txid, _)| !txids.contains(txid)); + } + + /// Resolve an accepted broadcast while retaining multi-hop RBF lineage until confirmation. + pub(crate) fn clear_broadcast_intent(&self, txid: &Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let (intent_key, intent) = + self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; + self.resolve_broadcast_intent(intent_key, intent, *txid, false) + } + + /// Restore unresolved intent reservations after loading the wallet from persistent storage. + pub(crate) fn restore_pending_broadcasts(&self) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + let stored_intents = self.load_broadcast_intents_from_store()?; + self.broadcast_intents.lock().unwrap().extend(stored_intents); + let confirmed_txids = self + .inner + .lock() + .unwrap() + .transaction_confirmations() + .keys() + .copied() + .collect::>(); + let confirmed_resolutions = observed_broadcast_intents( + &self.read_all_broadcast_intents()?, + &confirmed_txids, + &confirmed_txids, + ); + self.resolve_broadcast_intents(confirmed_resolutions)?; + self.complete_broadcast_transitions()?; + let pending_intents = self.read_all_broadcast_intents()?; + if pending_intents.is_empty() { + return Ok(()); + } + + let mut locked_wallet = self.inner.lock().unwrap(); + let confirmed_txids = + locked_wallet.transaction_confirmations().keys().copied().collect::>(); + let confirmed_intent_keys = pending_intents + .iter() + .filter(|(_, intent)| { + intent.transactions.iter().any(|tx| confirmed_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) + .collect::>(); + let unresolved_txs = pending_intents + .into_iter() + .filter(|(key, intent)| { + intent.has_pending_transaction() && !confirmed_intent_keys.contains(key) + }) + .map(|(_, intent)| intent) + .collect(); + self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + self.remove_broadcast_intents(confirmed_intent_keys) + } + + fn reapply_unresolved_broadcasts( + &self, locked_wallet: &mut AggregateWallet, + intents: Vec, + ) -> Result<(), Error> { + if intents.is_empty() { + return Ok(()); + } + let mut unconfirmed_txs = Vec::with_capacity(intents.len()); + for intent in intents { + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let last_seen = match Self::next_broadcast_timestamp(locked_wallet, &lineage_txids) { + Ok(last_seen) => last_seen, + Err(_) if locked_wallet.find_tx(intent.active_txid()).is_some() => continue, + Err(e) => return Err(e), + }; + unconfirmed_txs.push((intent.active_transaction().clone(), last_seen)); + } + locked_wallet.apply_mempool_txs(unconfirmed_txs, Vec::new()).map_err(|e| { + log_error!(self.logger, "Failed to preserve unresolved broadcast intents: {}", e); + Error::PersistenceFailed + }) + } + + fn next_broadcast_timestamp( + locked_wallet: &AggregateWallet, + txids: &[Txid], + ) -> Result { + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let tracked_txids = txids.iter().copied().collect::>(); + let latest_seen = locked_wallet + .unconfirmed_txids_with_last_seen() + .into_iter() + .filter(|(txid, _)| tracked_txids.contains(txid)) + .map(|(_, last_seen)| last_seen) + .max() + .unwrap_or(0); + let latest_evicted = locked_wallet + .wallets() + .values() + .flat_map(|wallet| { + txids.iter().filter_map(|txid| wallet.tx_graph().get_last_evicted(*txid)) + }) + .max() + .unwrap_or(0); + now.max(latest_seen) + .max(latest_evicted) + .checked_add(1) + .filter(|×tamp| timestamp < u64::MAX) + .ok_or(Error::WalletOperationFailed) + } + + fn write_broadcast_intent(&self, intent: &BroadcastIntent) -> Result<(), Error> { + if !intent.is_valid() { + log_error!(self.logger, "Refusing to persist an invalid broadcast intent"); + return Err(Error::PersistenceFailed); + } + let intent_key = intent.key(); + let mut bytes = vec![ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION]; + bytes.extend(serialize(&( + intent.active_index, + intent.state.serialization_tag(), + &intent.predecessor_indexes, + &intent.predecessor_was_pending, + &intent.transactions, + ))); + KVStoreSync::write( + &*self.kv_store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &intent_key.to_string(), + bytes, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to persist broadcast intent {}: {}", intent_key, e); + Error::PersistenceFailed + })?; + self.broadcast_intents.lock().unwrap().insert(intent_key, intent.clone()); + Ok(()) + } + + fn find_broadcast_intent_by_active_txid(&self, txid: &Txid) -> Option<(Txid, BroadcastIntent)> { + self.broadcast_intents + .lock() + .unwrap() + .iter() + .find(|(_, intent)| intent.active_txid() == *txid) + .map(|(key, intent)| (*key, intent.clone())) + } + + fn read_broadcast_intent_from_store( + &self, intent_key: &Txid, + ) -> Result, Error> { + let bytes = match KVStoreSync::read( + &*self.kv_store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &intent_key.to_string(), + ) { + Ok(bytes) => bytes, + Err(e) if e.kind() == lightning::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + log_error!(self.logger, "Failed to read broadcast intent {}: {}", intent_key, e); + return Err(Error::PersistenceFailed); + }, + }; + + let intent = match bytes.first() { + Some(&LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let tx = deserialize::(&bytes[1..]).map_err(|e| { + log_error!( + self.logger, + "Failed to decode broadcast intent {}: {}", + intent_key, + e + ); + Error::PersistenceFailed + })?; + BroadcastIntent::new(tx) + }, + Some(&LEGACY_RBF_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let transactions = deserialize::>(&bytes[1..]).map_err(|e| { + log_error!( + self.logger, + "Failed to decode broadcast intent {}: {}", + intent_key, + e + ); + Error::PersistenceFailed + })?; + BroadcastIntent::from_legacy(transactions, 0)? + }, + Some(&LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let (first_pending_index, transactions) = + deserialize::<(u32, Vec)>(&bytes[1..]).map_err(|e| { + log_error!( + self.logger, + "Failed to decode broadcast intent {}: {}", + intent_key, + e + ); + Error::PersistenceFailed + })?; + BroadcastIntent::from_legacy(transactions, first_pending_index)? + }, + Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let ( + active_index, + state_tag, + predecessor_indexes, + predecessor_was_pending, + transactions, + ) = deserialize::<(u32, u8, Vec, Vec, Vec)>(&bytes[1..]) + .map_err(|e| { + log_error!( + self.logger, + "Failed to decode broadcast intent {}: {}", + intent_key, + e + ); + Error::PersistenceFailed + })?; + let state = + BroadcastIntentState::from_serialization_tag(state_tag).map_err(|_| { + log_error!( + self.logger, + "Broadcast intent {} has an invalid state", + intent_key + ); + Error::PersistenceFailed + })?; + BroadcastIntent { + transactions, + active_index, + state, + predecessor_indexes, + predecessor_was_pending, + } + }, + _ => { + log_error!(self.logger, "Unsupported broadcast intent version for {}", intent_key); + return Err(Error::PersistenceFailed); + }, + }; + if !intent.is_valid() { + log_error!(self.logger, "Broadcast intent {} has invalid lineage", intent_key); + return Err(Error::PersistenceFailed); + } + if intent.key() != *intent_key { + log_error!( + self.logger, + "Broadcast intent key does not match transaction {}", + intent_key + ); + return Err(Error::PersistenceFailed); + } + Ok(Some(intent)) + } + + fn read_all_broadcast_intents(&self) -> Result, Error> { + let mut intents = self + .broadcast_intents + .lock() + .unwrap() + .iter() + .map(|(key, intent)| (*key, intent.clone())) + .collect::>(); + intents.sort_unstable_by_key(|(key, _)| *key); + Ok(intents) + } + + fn load_broadcast_intents_from_store(&self) -> Result, Error> { + let mut keys = KVStoreSync::list( + &*self.kv_store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to list broadcast intents: {}", e); + Error::PersistenceFailed + })?; + keys.sort_unstable(); + keys.into_iter() + .map(|key| { + let txid = Txid::from_str(&key).map_err(|e| { + log_error!(self.logger, "Invalid broadcast intent key {}: {}", key, e); + Error::PersistenceFailed + })?; + let intent = self + .read_broadcast_intent_from_store(&txid)? + .ok_or(Error::PersistenceFailed)?; + Ok((txid, intent)) + }) + .collect() + } + + fn remove_broadcast_intent(&self, txid: &Txid) -> Result<(), Error> { + KVStoreSync::remove( + &*self.kv_store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &txid.to_string(), + false, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to remove broadcast intent {}: {}", txid, e); + Error::PersistenceFailed + })?; + self.broadcast_intents.lock().unwrap().remove(txid); + Ok(()) } - // Bumps the fee of an existing transaction using Replace-By-Fee (RBF). - // Returns the txid of the new transaction if successful. - pub(crate) fn bump_fee_by_rbf( + fn remove_broadcast_intents(&self, txids: impl IntoIterator) -> Result<(), Error> { + for txid in txids { + self.remove_broadcast_intent(&txid)?; + } + Ok(()) + } + + fn stage_broadcast_resolutions(&self, resolutions: &[(Txid, Txid, bool)]) -> Result<(), Error> { + for (intent_key, observed_txid, confirmed) in resolutions { + let mut intent = self + .broadcast_intents + .lock() + .unwrap() + .get(intent_key) + .cloned() + .ok_or(Error::TransactionNotFound)?; + if *confirmed { + intent.begin_confirmation(*observed_txid)?; + } else { + intent.begin_observation(*observed_txid)?; + } + self.write_broadcast_intent(&intent)?; + } + Ok(()) + } + + fn complete_broadcast_transitions(&self) -> Result<(), Error> { + let transitions = self + .read_all_broadcast_intents()? + .into_iter() + .filter(|(_, intent)| { + matches!( + intent.state, + BroadcastIntentState::Rejecting + | BroadcastIntentState::Abandoning + | BroadcastIntentState::Observing + | BroadcastIntentState::Confirming + ) + }) + .collect::>(); + for (intent_key, intent) in transitions { + match intent.state { + BroadcastIntentState::Rejecting => { + self.complete_rejection(intent_key, intent)?; + }, + BroadcastIntentState::Abandoning => { + self.complete_abandon(intent_key, intent)?; + }, + BroadcastIntentState::Observing => { + self.complete_observation(intent_key, intent)?; + }, + BroadcastIntentState::Confirming => { + self.complete_confirmation(intent_key, intent)?; + }, + _ => {}, + } + } + Ok(()) + } + + fn complete_confirmation( + &self, intent_key: Txid, mut intent: BroadcastIntent, + ) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Confirming { + return Err(Error::PersistenceFailed); + } + let confirmed_txid = intent.active_txid(); + let mut locked_wallet = self.inner.lock().unwrap(); + if !locked_wallet.transaction_confirmations().contains_key(&confirmed_txid) { + drop(locked_wallet); + intent.state = BroadcastIntentState::Observing; + self.write_broadcast_intent(&intent)?; + return self.complete_observation(intent_key, intent); + } + locked_wallet.persist_all().map_err(|e| { + log_error!( + self.logger, + "Failed to durably persist confirmed broadcast {}: {}", + confirmed_txid, + e + ); + Error::PersistenceFailed + })?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + if self.payment_store.get(&PaymentId(confirmed_txid.to_byte_array())).is_none() { + log_error!( + self.logger, + "Confirmed broadcast {} is missing from the payment store", + confirmed_txid + ); + return Err(Error::PersistenceFailed); + } + for tx in &intent.transactions { + let txid = tx.compute_txid(); + if txid != confirmed_txid { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } + self.remove_broadcast_intent(&intent_key) + } + + fn complete_observation( + &self, intent_key: Txid, mut intent: BroadcastIntent, + ) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Observing { + return Err(Error::PersistenceFailed); + } + let observed_tx = intent.active_transaction().clone(); + let observed_txid = observed_tx.compute_txid(); + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let mut locked_wallet = self.inner.lock().unwrap(); + let observed_at = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; + locked_wallet.apply_mempool_txs(vec![(observed_tx, observed_at)], Vec::new()).map_err( + |e| { + log_error!( + self.logger, + "Failed to restore observed transaction {}: {}", + observed_txid, + e + ); + Error::PersistenceFailed + }, + )?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + + intent.state = BroadcastIntentState::Accepted; + if intent.transactions.len() == 1 { + self.remove_broadcast_intent(&intent_key) + } else { + self.write_broadcast_intent(&intent) + } + } + + fn resolve_broadcast_intents( + &self, resolutions: impl IntoIterator, + ) -> Result<(), Error> { + for (intent_key, observed_txid, confirmed) in resolutions { + let intent = self + .broadcast_intents + .lock() + .unwrap() + .get(&intent_key) + .cloned() + .ok_or(Error::TransactionNotFound)?; + self.resolve_broadcast_intent(intent_key, intent, observed_txid, confirmed)?; + } + Ok(()) + } + + fn resolve_broadcast_intent( + &self, intent_key: Txid, mut intent: BroadcastIntent, observed_txid: Txid, confirmed: bool, + ) -> Result<(), Error> { + if confirmed { + if intent.state != BroadcastIntentState::Confirming + || intent.active_txid() != observed_txid + { + intent.begin_confirmation(observed_txid)?; + self.write_broadcast_intent(&intent)?; + } + return self.complete_confirmation(intent_key, intent); + } + + intent.mark_accepted(observed_txid)?; + if intent.transactions.len() == 1 { + self.remove_broadcast_intent(&intent_key)?; + } else { + self.write_broadcast_intent(&intent)?; + } + for tx in &intent.transactions { + let txid = tx.compute_txid(); + if txid != observed_txid { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } + Ok(()) + } + + /// Builds, persists, and reserves an RBF replacement before backend dispatch. + /// + /// If `txid` has unresolved or accepted replacement lineage, the new replacement is appended to + /// the same persisted intent record. One store write then atomically makes the replacement the + /// only transaction exposed for retry while retaining its predecessors until confirmation. + pub(crate) fn prepare_rbf_broadcast( &self, txid: &Txid, fee_rate: FeeRate, channel_manager: &ChannelManager, - ) -> Result { + ) -> Result { // Check if this is a funding transaction if self.is_funding_transaction(txid, channel_manager) { log_error!( @@ -837,6 +1880,9 @@ impl Wallet { return Err(Error::CannotRbfFundingTransaction); } + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let existing_intent = self.find_broadcast_intent_by_active_txid(txid); let mut locked_wallet = self.inner.lock().unwrap(); let (tx, original_fee) = locked_wallet.build_rbf(*txid, fee_rate).map_err(|e| match e { @@ -872,16 +1918,24 @@ impl Wallet { }, })?; - // Persist wallet changes - locked_wallet.persist_all().map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - - // Extract and broadcast the transaction - self.broadcaster.broadcast_transactions(&[&tx]); - let new_txid = tx.compute_txid(); + let original_tx = locked_wallet.find_tx(*txid).ok_or(Error::TransactionNotFound)?; + let replacement_intent = BroadcastIntent::replacement( + existing_intent.map(|(_, intent)| intent), + original_tx, + tx.clone(), + )?; + let last_seen = self.write_rbf_replacement_intent(&locked_wallet, &replacement_intent)?; + self.payment_store_update_pending.store(true, Ordering::Release); + if let Err(e) = locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) { + log_error!(self.logger, "Failed to reserve RBF replacement {}: {}", new_txid, e); + return Err(Error::OnchainTxBroadcastFailed { txid: new_txid }); + } + if let Err(e) = self.update_payment_store(&locked_wallet) { + log_error!(self.logger, "Failed to index RBF replacement {}: {}", new_txid, e); + return Err(Error::OnchainTxBroadcastFailed { txid: new_txid }); + } + self.note_locally_applied_unconfirmed(new_txid); // Calculate and log the actual fee increase achieved let new_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); @@ -902,7 +1956,76 @@ impl Wallet { new_fee.to_sat().saturating_sub(original_fee.to_sat()) ); - Ok(new_txid) + Ok(tx) + } + + fn write_rbf_replacement_intent( + &self, locked_wallet: &AggregateWallet, + replacement_intent: &BroadcastIntent, + ) -> Result { + let lineage_txids = + replacement_intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let last_seen = Self::next_broadcast_timestamp(locked_wallet, &lineage_txids)?; + self.write_broadcast_intent(replacement_intent)?; + Ok(last_seen) + } + + /// Roll back a conclusively rejected or undispatched RBF replacement. + pub(crate) fn reject_rbf_broadcast(&self, replacement_txid: &Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + let (intent_key, mut intent) = self + .find_broadcast_intent_by_active_txid(replacement_txid) + .ok_or(Error::TransactionNotFound)?; + intent.begin_rejection()?; + self.write_broadcast_intent(&intent)?; + self.complete_rejection(intent_key, intent) + } + + fn complete_rejection( + &self, intent_key: Txid, mut intent: BroadcastIntent, + ) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Rejecting { + return Err(Error::PersistenceFailed); + } + let rejected_tx = intent.active_transaction().clone(); + let rejected_txid = rejected_tx.compute_txid(); + let predecessor_index = intent.predecessor_indexes[intent.active_index as usize]; + let predecessor_index = + usize::try_from(predecessor_index).map_err(|_| Error::PersistenceFailed)?; + let predecessor_tx = + intent.transactions.get(predecessor_index).cloned().ok_or(Error::PersistenceFailed)?; + let mut locked_wallet = self.inner.lock().unwrap(); + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + locked_wallet.abandon_txs(std::slice::from_ref(&rejected_tx)).map_err(|e| { + log_error!(self.logger, "Failed to reject RBF replacement {}: {}", rejected_txid, e); + Error::PersistenceFailed + })?; + let restored_txid = predecessor_tx.compute_txid(); + let restored_at = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; + locked_wallet.apply_mempool_txs(vec![(predecessor_tx, restored_at)], Vec::new()).map_err( + |e| { + log_error!( + self.logger, + "Failed to restore superseded transaction {}: {}", + restored_txid, + e + ); + Error::PersistenceFailed + }, + )?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + self.payment_store.remove(&PaymentId(rejected_txid.to_byte_array()))?; + self.forget_locally_applied_unconfirmed(&[rejected_txid]); + + let finished_rejected_tx = intent.finish_rejection()?; + debug_assert_eq!(finished_rejected_tx.compute_txid(), rejected_txid); + if intent.has_pending_transaction() || intent.transactions.len() > 1 { + self.write_broadcast_intent(&intent) + } else { + self.remove_broadcast_intent(&intent_key) + } } // Accelerates confirmation of a transaction using Child-Pays-For-Parent (CPFP). @@ -994,8 +2117,12 @@ impl Wallet { } pub(crate) fn update_payment_store_for_all_transactions(&self) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; let locked_wallet = self.inner.lock().unwrap(); - self.update_payment_store(&locked_wallet) + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + Ok(()) } fn update_payment_store( @@ -1088,6 +2215,25 @@ impl Wallet { } } + // Only the canonical member of an unresolved replacement chain is user-visible. Keeping the + // other raw transactions in the intent is necessary for backend reconciliation, but keeping + // their payment records would expose one logical payment more than once. + self.remove_noncanonical_broadcast_payments(&seen_txids)?; + + Ok(()) + } + + fn remove_noncanonical_broadcast_payments( + &self, canonical_txids: &HashSet, + ) -> Result<(), Error> { + for (_, intent) in self.read_all_broadcast_intents()? { + for tx in intent.transactions { + let txid = tx.compute_txid(); + if !canonical_txids.contains(&txid) { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } + } Ok(()) } @@ -1859,10 +3005,10 @@ impl Wallet { } #[allow(deprecated)] - pub(crate) fn send_to_address( + pub(crate) fn create_send_to_address_transaction( &self, address: &Address, send_amount: OnchainSendAmount, fee_rate: Option, utxos_to_spend: Option>, channel_manager: &ChannelManager, - ) -> Result { + ) -> Result { self.parse_and_validate_address(&address)?; // Use the set fee_rate or default to fee estimation. @@ -1912,8 +3058,6 @@ impl Wallet { tx }; - self.broadcaster.broadcast_transactions(&[&tx]); - let txid = tx.compute_txid(); match send_amount { @@ -1945,7 +3089,7 @@ impl Wallet { }, } - Ok(txid) + Ok(tx) } pub(crate) fn select_confirmed_utxos( @@ -2143,6 +3287,28 @@ impl Listen for Wallet { } fn block_connected(&self, block: &bitcoin::Block, height: u32) { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + if let Err(e) = self.complete_broadcast_transitions() { + log_error!(self.logger, "Failed to complete broadcast transition: {}", e); + return; + } + let pending_intents = match self.read_all_broadcast_intents() { + Ok(intents) => intents, + Err(e) => { + log_error!( + self.logger, + "Failed to read broadcast intents before block update: {}", + e + ); + return; + }, + }; + let block_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); + let resolutions = observed_broadcast_intents(&pending_intents, &block_txids, &block_txids); + if let Err(e) = self.stage_broadcast_resolutions(&resolutions) { + log_error!(self.logger, "Failed to stage confirmed broadcasts: {}", e); + return; + } let mut locked_wallet = self.inner.lock().unwrap(); let pre_checkpoint = locked_wallet.latest_checkpoint(); @@ -2164,6 +3330,10 @@ impl Listen for Wallet { log_error!(self.logger, "Failed to update payment store: {}", e); return; } + drop(locked_wallet); + if let Err(e) = self.resolve_broadcast_intents(resolutions) { + log_error!(self.logger, "Failed to reconcile confirmed broadcasts: {}", e); + } }, Err(e) => { log_error!( @@ -2409,13 +3579,203 @@ impl ChangeDestinationSource for WalletKeysManager { #[cfg(test)] mod tests { use super::{ - additional_input_weight, map_wallet_account_error, validate_derivation_index, - validate_derivation_range, BIP32_MAX_NORMAL_INDEX, MAX_ADDRESS_INFO_BATCH_COUNT, + additional_input_weight, map_wallet_account_error, observed_broadcast_intents, + validate_derivation_index, validate_derivation_range, BroadcastIntent, + BroadcastIntentState, BIP32_MAX_NORMAL_INDEX, + LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION, + LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION, MAX_ADDRESS_INFO_BATCH_COUNT, }; + use crate::builder::NodeBuilder; use crate::config::{AddressType, OnchainWalletAccount}; + use crate::io::{ + test_utils::InMemoryStore, BDK_WALLET_TX_GRAPH_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + }; + use crate::payment::{ + ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, + }; + use crate::types::DynStore; use crate::Error; use bdk_wallet_aggregate::UtxoPsbtInfo; - use bitcoin::{psbt, OutPoint, TxIn, Weight}; + use bitcoin::absolute::LockTime; + use bitcoin::block::Header; + use bitcoin::blockdata::constants::genesis_block; + use bitcoin::consensus::serialize; + use bitcoin::hashes::Hash; + use bitcoin::transaction::Version; + use bitcoin::{ + psbt, Amount, Block, Network, OutPoint, Transaction, TxIn, TxMerkleNode, TxOut, Weight, + }; + use lightning::io; + use lightning::ln::channelmanager::PaymentId; + use lightning::util::persist::{KVStore, KVStoreSync}; + use std::collections::HashSet; + use std::future::Future; + use std::pin::Pin; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::sync::Mutex; + use std::thread; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + struct NamespaceFailStore { + inner: InMemoryStore, + fail_next_write_namespace: Mutex>, + fail_next_remove_namespace: Mutex>, + } + + impl NamespaceFailStore { + fn new() -> Self { + Self { + inner: InMemoryStore::new(), + fail_next_write_namespace: Mutex::new(None), + fail_next_remove_namespace: Mutex::new(None), + } + } + + fn fail_next_write_in(&self, primary_namespace: &str) { + *self.fail_next_write_namespace.lock().unwrap() = Some(primary_namespace.to_owned()); + } + + fn fail_next_remove_in(&self, primary_namespace: &str) { + *self.fail_next_remove_namespace.lock().unwrap() = Some(primary_namespace.to_owned()); + } + + fn take_failure(slot: &Mutex>, primary_namespace: &str) -> bool { + let mut slot = slot.lock().unwrap(); + if slot.as_deref() == Some(primary_namespace) { + slot.take(); + true + } else { + false + } + } + + fn write_result(&self, primary_namespace: &str) -> io::Result<()> { + if Self::take_failure(&self.fail_next_write_namespace, primary_namespace) { + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace write failure")) + } else { + Ok(()) + } + } + + fn remove_result(&self, primary_namespace: &str) -> io::Result<()> { + if Self::take_failure(&self.fail_next_remove_namespace, primary_namespace) { + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace remove failure")) + } else { + Ok(()) + } + } + } + + impl KVStore for NamespaceFailStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Pin>> + Send + 'static>> { + KVStore::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Pin> + Send + 'static>> { + let result = self.write_result(primary_namespace); + if result.is_err() { + return Box::pin(async move { result }); + } + KVStore::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Pin> + Send + 'static>> { + let result = self.remove_result(primary_namespace); + if result.is_err() { + return Box::pin(async move { result }); + } + KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Pin>> + Send + 'static>> { + KVStore::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + impl KVStoreSync for NamespaceFailStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + KVStoreSync::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> io::Result<()> { + self.write_result(primary_namespace)?; + KVStoreSync::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> io::Result<()> { + self.remove_result(primary_namespace)?; + KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> io::Result> { + KVStoreSync::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + fn replacement_test_transaction(lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: vec![TxIn { previous_output: OutPoint::null(), ..TxIn::default() }], + output: vec![], + } + } + + fn replacement_test_confirmation_block(transaction: Transaction) -> Block { + let genesis = genesis_block(Network::Regtest); + Block { + header: Header { + version: bitcoin::block::Version::ONE, + prev_blockhash: genesis.block_hash(), + merkle_root: TxMerkleNode::from_byte_array( + transaction.compute_txid().to_byte_array(), + ), + time: genesis.header.time.saturating_add(1), + bits: genesis.header.bits, + nonce: 1, + }, + txdata: vec![transaction], + } + } + + fn replacement_test_node(store: Arc) -> crate::Node { + let config = crate::Config { network: Network::Regtest, ..crate::Config::default() }; + let mut builder = NodeBuilder::from_config(config); + builder.set_chain_source_esplora("http://127.0.0.1:1".to_string(), None); + builder.set_entropy_seed_bytes([44u8; 64]); + builder.set_log_facade_logger(); + builder.build_with_store(store).unwrap() + } + + fn replacement_test_payment(txid: bitcoin::Txid) -> PaymentDetails { + PaymentDetails::new( + PaymentId(txid.to_byte_array()), + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed }, + Some(1_000), + Some(100), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ) + } #[test] fn derivation_index_validation_rejects_hardened_range() { @@ -2485,4 +3845,816 @@ mod tests { TxIn::default().segwit_weight() + satisfaction_weight ); } + + #[test] + fn rbf_supersession_preserves_the_intent_key_and_changes_only_the_active_txid() { + let original = replacement_test_transaction(1); + let replacement = replacement_test_transaction(2); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original.clone()); + + intent.supersede(replacement.clone()).unwrap(); + + assert_eq!(intent.key(), original_txid); + assert_eq!(intent.active_txid(), replacement_txid); + assert_eq!(intent.transactions, vec![original, replacement]); + } + + #[test] + fn ordinary_rbf_retains_the_accepted_original_only_for_reconciliation() { + let original = replacement_test_transaction(8); + let replacement = replacement_test_transaction(9); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + + assert_eq!(intent.key(), original_txid); + assert_eq!(intent.active_txid(), replacement_txid); + assert_eq!(intent.state, BroadcastIntentState::Pending); + assert_eq!(intent.predecessor_was_pending, vec![0, 0]); + assert_eq!(intent.transactions, vec![original, replacement]); + } + + #[test] + fn same_second_rbf_timestamp_makes_the_replacement_canonical() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(14); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(15); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let original_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(1); + let mut wallet = node.wallet.inner.lock().unwrap(); + wallet.apply_mempool_txs(vec![(original.clone(), original_seen)], Vec::new()).unwrap(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + + let replacement_seen = + super::Wallet::next_broadcast_timestamp(&wallet, &lineage_txids).unwrap(); + wallet + .apply_mempool_txs(vec![(replacement.clone(), replacement_seen)], Vec::new()) + .unwrap(); + assert_eq!(wallet.find_tx(replacement_txid), Some(replacement)); + assert_eq!(wallet.find_tx(original_txid), None); + node.wallet.update_payment_store(&wallet).unwrap(); + drop(wallet); + + assert!(replacement_seen > original_seen); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); + assert!(node.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + } + + #[test] + fn restart_after_rbf_intent_persistence_uses_the_full_lineage_timestamp() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(19); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(20); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let future_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(60); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(original.clone(), future_seen)], Vec::new()) + .unwrap(); + let intent = BroadcastIntent::replacement(None, original, replacement.clone()).unwrap(); + // Simulate a crash after the intent write and before BDK applies the replacement. + node.wallet.write_broadcast_intent(&intent).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + let wallet = restarted.wallet.inner.lock().unwrap(); + assert_eq!(wallet.find_tx(replacement_txid), Some(replacement)); + assert_eq!(wallet.find_tx(original_txid), None); + } + + #[test] + fn explicit_recovery_uses_the_full_lineage_timestamp() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(21); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(22); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let future_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(60); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(original.clone(), future_seen)], Vec::new()) + .unwrap(); + let intent = BroadcastIntent::replacement(None, original, replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + assert_eq!( + node.wallet.recover_pending_broadcast(&replacement_txid).unwrap(), + Some(replacement.clone()) + ); + let wallet = node.wallet.inner.lock().unwrap(); + assert_eq!(wallet.find_tx(replacement_txid), Some(replacement)); + assert_eq!(wallet.find_tx(original_txid), None); + } + + #[test] + fn rbf_supersession_rejects_a_transaction_that_does_not_conflict() { + let original = replacement_test_transaction(1); + let mut replacement = replacement_test_transaction(2); + replacement.input[0].previous_output.vout = 1; + let mut intent = BroadcastIntent::new(original.clone()); + + assert_eq!(intent.supersede(replacement), Err(Error::OnchainTxCreationFailed)); + assert_eq!(intent.transactions, vec![original]); + } + + #[test] + fn rejected_rbf_replacement_atomically_restores_the_previous_retry_target() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let original = replacement_test_transaction(3); + let replacement = replacement_test_transaction(4); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original.clone()); + intent.supersede(replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); + + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![original_txid]); + assert_eq!(node.wallet.recover_pending_broadcast(&original_txid).unwrap(), Some(original)); + drop(node); + let restarted = replacement_test_node(store); + assert_eq!(restarted.wallet.list_pending_broadcasts().unwrap(), vec![original_txid]); + } + + #[test] + fn rejected_rbf_clears_the_locally_applied_unconfirmed_marker() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(3); + let replacement = replacement_test_transaction(4); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original); + intent.supersede(replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.note_locally_applied_unconfirmed(replacement_txid); + + node.wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); + + assert!(!node.wallet.take_locally_applied_unconfirmed_txids().contains(&replacement_txid)); + } + + #[test] + fn unpublished_locally_applied_marker_is_not_consumed() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let txid = replacement_test_transaction(40).compute_txid(); + node.wallet.note_locally_applied_unconfirmed(txid); + assert!(node.wallet.take_locally_applied_unconfirmed_txids().is_empty()); + node.wallet.publish_locally_applied_unconfirmed(txid); + assert_eq!(node.wallet.take_locally_applied_unconfirmed_txids(), vec![txid]); + } + + #[test] + fn concurrent_rbf_rejection_does_not_publish_locally_applied_marker() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(3); + let replacement = replacement_test_transaction(4); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original); + intent.supersede(replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + let wallet = Arc::clone(&node.wallet); + let seen = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let taker_wallet = Arc::clone(&wallet); + let taker_seen = Arc::clone(&seen); + let taker_stop = Arc::clone(&stop); + let taker = thread::spawn(move || { + while !taker_stop.load(Ordering::Relaxed) { + taker_seen + .lock() + .unwrap() + .extend(taker_wallet.take_locally_applied_unconfirmed_txids()); + thread::yield_now(); + } + taker_seen + .lock() + .unwrap() + .extend(taker_wallet.take_locally_applied_unconfirmed_txids()); + }); + + wallet.note_locally_applied_unconfirmed(replacement_txid); + thread::sleep(Duration::from_millis(10)); + wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); + stop.store(true, Ordering::Relaxed); + taker.join().unwrap(); + + assert!(!seen.lock().unwrap().contains(&replacement_txid)); + assert!(!wallet.take_locally_applied_unconfirmed_txids().contains(&replacement_txid)); + } + + #[test] + fn observing_an_rbf_predecessor_keeps_the_active_replacement_pending() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(5); + let replacement = replacement_test_transaction(6); + let replacement_txid = replacement.compute_txid(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet.apply_mempool_txs(vec![(original, 1)], Vec::new()).unwrap(); + + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![replacement_txid]); + assert_eq!( + node.wallet.list_pending_broadcast_infos().unwrap(), + vec![(replacement_txid, vec![intent.key(), replacement_txid])] + ); + } + + #[test] + fn terminal_last_seen_does_not_persist_max_or_fail_restart() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(29); + original.input[0].previous_output.vout = 1; + original.output.push(TxOut { value: Amount::from_sat(10_000), script_pubkey }); + let txid = original.compute_txid(); + node.wallet.prepare_pending_broadcast(&original).unwrap(); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(original.clone(), u64::MAX - 1)], Vec::new()) + .unwrap(); + { + let wallet = node.wallet.inner.lock().unwrap(); + assert_eq!( + super::Wallet::next_broadcast_timestamp(&wallet, &[txid]), + Err(Error::WalletOperationFailed) + ); + } + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + drop(node); + + let restarted = replacement_test_node(store); + assert_eq!(restarted.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(restarted.wallet.recover_pending_broadcast(&txid).unwrap(), Some(original)); + } + + #[test] + fn rbf_does_not_persist_replacement_when_lineage_last_seen_is_terminal() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(38); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(39); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + node.wallet.prepare_pending_broadcast(&original).unwrap(); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(original.clone(), u64::MAX - 1)], Vec::new()) + .unwrap(); + let existing_intent = node + .wallet + .find_broadcast_intent_by_active_txid(&original_txid) + .map(|(_, intent)| intent); + let replacement_intent = + BroadcastIntent::replacement(existing_intent, original.clone(), replacement).unwrap(); + { + let wallet = node.wallet.inner.lock().unwrap(); + assert_eq!( + node.wallet.write_rbf_replacement_intent(&wallet, &replacement_intent), + Err(Error::WalletOperationFailed) + ); + } + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![original_txid]); + drop(node); + + let restarted = replacement_test_node(store); + assert_eq!(restarted.wallet.list_pending_broadcasts().unwrap(), vec![original_txid]); + assert_eq!( + restarted.wallet.recover_pending_broadcast(&original_txid).unwrap(), + Some(original) + ); + } + + #[test] + fn confirming_an_rbf_predecessor_resolves_the_active_replacement_intent() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(5); + let replacement = replacement_test_transaction(6); + let original_txid = original.compute_txid(); + let intent = BroadcastIntent::replacement(None, original.clone(), replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet + .resolve_broadcast_intents(observed_broadcast_intents( + &[(intent.key(), intent)], + &HashSet::new(), + &HashSet::from([original_txid]), + )) + .unwrap(); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + } + + #[test] + fn rejected_ordinary_rbf_does_not_turn_the_accepted_original_into_a_retry_target() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(10); + let replacement = replacement_test_transaction(11); + let replacement_txid = replacement.compute_txid(); + let intent = BroadcastIntent::replacement(None, original, replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + } + + #[test] + fn rejection_transition_completes_after_restart() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let original = replacement_test_transaction(23); + let accepted_replacement = replacement_test_transaction(24); + let rejected_replacement = replacement_test_transaction(25); + let accepted_txid = accepted_replacement.compute_txid(); + let rejected_txid = rejected_replacement.compute_txid(); + let first_intent = + BroadcastIntent::replacement(None, original.clone(), accepted_replacement.clone()) + .unwrap(); + node.wallet.write_broadcast_intent(&first_intent).unwrap(); + node.wallet.clear_broadcast_intent(&accepted_txid).unwrap(); + let (_, accepted_intent) = + node.wallet.find_broadcast_intent_by_active_txid(&accepted_txid).unwrap(); + let mut rejecting_intent = BroadcastIntent::replacement( + Some(accepted_intent), + accepted_replacement.clone(), + rejected_replacement, + ) + .unwrap(); + node.wallet.write_broadcast_intent(&rejecting_intent).unwrap(); + node.payment_store.insert(replacement_test_payment(rejected_txid)).unwrap(); + rejecting_intent.begin_rejection().unwrap(); + // Simulate a crash before the fallible BDK and payment cleanup phase. + node.wallet.write_broadcast_intent(&rejecting_intent).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert!(restarted.wallet.list_pending_broadcasts().unwrap().is_empty()); + let (_, restored_intent) = + restarted.wallet.find_broadcast_intent_by_active_txid(&accepted_txid).unwrap(); + assert_eq!(restored_intent.transactions, vec![original, accepted_replacement]); + assert_eq!(restored_intent.state, BroadcastIntentState::Accepted); + assert!(restarted.payment(&PaymentId(rejected_txid.to_byte_array())).is_none()); + } + + #[test] + fn abandon_transition_completes_after_restart() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut tx = replacement_test_transaction(33); + tx.input[0].previous_output.vout = 1; + tx.output.push(TxOut { value: Amount::from_sat(10_000), script_pubkey }); + let txid = tx.compute_txid(); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + let (_, mut intent) = node.wallet.find_broadcast_intent_by_active_txid(&txid).unwrap(); + intent.begin_abandon().unwrap(); + // Simulate a crash before the fallible BDK and payment cleanup phase. + node.wallet.write_broadcast_intent(&intent).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert!(restarted.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert!(restarted.wallet.read_all_broadcast_intents().unwrap().is_empty()); + assert_eq!(restarted.wallet.inner.lock().unwrap().find_tx(txid), None); + assert!(restarted.payment(&PaymentId(txid.to_byte_array())).is_none()); + } + + #[test] + fn rbf_payment_history_persists_only_the_canonical_replacement() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let original = replacement_test_transaction(12); + let replacement = replacement_test_transaction(13); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let intent = BroadcastIntent::replacement(None, original, replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.payment_store.insert(replacement_test_payment(original_txid)).unwrap(); + node.payment_store.insert(replacement_test_payment(replacement_txid)).unwrap(); + + node.wallet + .remove_noncanonical_broadcast_payments(&HashSet::from([replacement_txid])) + .unwrap(); + + assert!(node.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); + let original_key = crate::hex_utils::to_string(&original_txid.to_byte_array()); + let replacement_key = crate::hex_utils::to_string(&replacement_txid.to_byte_array()); + assert!(KVStoreSync::read( + &*store, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + &original_key + ) + .is_err()); + assert!(KVStoreSync::read( + &*store, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + &replacement_key + ) + .is_ok()); + } + + #[test] + fn accepted_rbf_ancestors_survive_restart_until_a_lineage_member_confirms() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(16); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut first_replacement = replacement_test_transaction(17); + first_replacement.input[0].previous_output.vout = 1; + first_replacement + .output + .push(TxOut { value: Amount::from_sat(9_000), script_pubkey: script_pubkey.clone() }); + let mut second_replacement = replacement_test_transaction(18); + second_replacement.input[0].previous_output.vout = 1; + second_replacement.output.push(TxOut { value: Amount::from_sat(8_000), script_pubkey }); + let original_txid = original.compute_txid(); + let first_replacement_txid = first_replacement.compute_txid(); + let second_replacement_txid = second_replacement.compute_txid(); + let first_intent = + BroadcastIntent::replacement(None, original.clone(), first_replacement.clone()) + .unwrap(); + node.wallet.write_broadcast_intent(&first_intent).unwrap(); + node.wallet.clear_broadcast_intent(&first_replacement_txid).unwrap(); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + drop(node); + + let restarted = replacement_test_node(Arc::clone(&store)); + let (_, accepted_lineage) = + restarted.wallet.find_broadcast_intent_by_active_txid(&first_replacement_txid).unwrap(); + assert_eq!( + accepted_lineage.transactions, + vec![original.clone(), first_replacement.clone()] + ); + assert!(!accepted_lineage.has_pending_transaction()); + let second_intent = BroadcastIntent::replacement( + Some(accepted_lineage), + first_replacement.clone(), + second_replacement.clone(), + ) + .unwrap(); + restarted.wallet.write_broadcast_intent(&second_intent).unwrap(); + assert_eq!( + restarted.wallet.list_pending_broadcasts().unwrap(), + vec![second_replacement_txid] + ); + drop(restarted); + + let restarted_again = replacement_test_node(store); + let (_, uncertain_lineage) = restarted_again + .wallet + .find_broadcast_intent_by_active_txid(&second_replacement_txid) + .unwrap(); + assert_eq!( + uncertain_lineage.transactions, + vec![original.clone(), first_replacement, second_replacement] + ); + let block = replacement_test_confirmation_block(original); + restarted_again.wallet.inner.lock().unwrap().apply_block(&block, 1).unwrap(); + restarted_again + .wallet + .resolve_broadcast_intents([(original_txid, original_txid, true)]) + .unwrap(); + assert!(restarted_again.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert!(restarted_again.wallet.read_all_broadcast_intents().unwrap().is_empty()); + } + + #[test] + fn mempool_resolution_retains_branches_until_an_inactive_member_confirms() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(26); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut first_replacement = replacement_test_transaction(27); + first_replacement.input[0].previous_output.vout = 1; + first_replacement + .output + .push(TxOut { value: Amount::from_sat(9_000), script_pubkey: script_pubkey.clone() }); + let mut branch_replacement = replacement_test_transaction(28); + branch_replacement.input[0].previous_output.vout = 1; + branch_replacement.output.push(TxOut { value: Amount::from_sat(8_000), script_pubkey }); + let original_txid = original.compute_txid(); + let first_replacement_txid = first_replacement.compute_txid(); + let branch_replacement_txid = branch_replacement.compute_txid(); + let intent = + BroadcastIntent::replacement(None, original.clone(), first_replacement.clone()) + .unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.clear_broadcast_intent(&first_replacement_txid).unwrap(); + node.payment_store.insert(replacement_test_payment(original_txid)).unwrap(); + node.payment_store.insert(replacement_test_payment(first_replacement_txid)).unwrap(); + + node.wallet.resolve_broadcast_intents([(original_txid, original_txid, false)]).unwrap(); + let (_, observed_original) = + node.wallet.find_broadcast_intent_by_active_txid(&original_txid).unwrap(); + assert_eq!( + observed_original.transactions, + vec![original.clone(), first_replacement.clone()] + ); + assert_eq!(observed_original.state, BroadcastIntentState::Accepted); + assert!(node.payment(&PaymentId(first_replacement_txid.to_byte_array())).is_none()); + + let branch_intent = BroadcastIntent::replacement( + Some(observed_original), + original, + branch_replacement.clone(), + ) + .unwrap(); + node.wallet.write_broadcast_intent(&branch_intent).unwrap(); + node.payment_store.insert(replacement_test_payment(branch_replacement_txid)).unwrap(); + + // Simulate a crash after BDK persisted the inactive member's confirmation and before payment + // indexing or intent removal. The confirmed payment is deliberately absent here. + node.wallet + .stage_broadcast_resolutions(&[(original_txid, first_replacement_txid, true)]) + .unwrap(); + let block = replacement_test_confirmation_block(first_replacement); + node.wallet.inner.lock().unwrap().apply_block(&block, 1).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert!(restarted.wallet.read_all_broadcast_intents().unwrap().is_empty()); + assert!(restarted.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert!(restarted.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + assert!(restarted.payment(&PaymentId(first_replacement_txid.to_byte_array())).is_some()); + assert!(restarted.payment(&PaymentId(branch_replacement_txid.to_byte_array())).is_none()); + } + + #[test] + fn confirmation_transition_retries_payment_and_intent_store_failures_in_order() { + let concrete_store = Arc::new(NamespaceFailStore::new()); + let store: Arc = concrete_store.clone(); + let node = replacement_test_node(store); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(34); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(35); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.clear_broadcast_intent(&replacement_txid).unwrap(); + node.payment_store.insert(replacement_test_payment(original_txid)).unwrap(); + node.payment_store.insert(replacement_test_payment(replacement_txid)).unwrap(); + node.wallet.resolve_broadcast_intents([(original_txid, original_txid, false)]).unwrap(); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_none()); + + node.wallet + .stage_broadcast_resolutions(&[(original_txid, replacement_txid, true)]) + .unwrap(); + let block = replacement_test_confirmation_block(replacement); + node.wallet.inner.lock().unwrap().apply_block(&block, 1).unwrap(); + + concrete_store.fail_next_write_in(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + assert_eq!( + node.wallet.resolve_broadcast_intents([(original_txid, replacement_txid, true)]), + Err(Error::PersistenceFailed) + ); + let (_, confirming_intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(confirming_intent.state, BroadcastIntentState::Confirming); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_none()); + + concrete_store.fail_next_remove_in(ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.complete_broadcast_transitions(), Err(Error::PersistenceFailed)); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); + let (_, confirming_intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(confirming_intent.state, BroadcastIntentState::Confirming); + + node.wallet.complete_broadcast_transitions().unwrap(); + assert!(node.wallet.read_all_broadcast_intents().unwrap().is_empty()); + assert!(node.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); + } + + #[test] + fn confirmation_transition_survives_bdk_persistence_failure_and_restart() { + let concrete_store = Arc::new(NamespaceFailStore::new()); + let store: Arc = concrete_store.clone(); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(36); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(37); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let intent = BroadcastIntent::replacement(None, original, replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.clear_broadcast_intent(&replacement_txid).unwrap(); + node.payment_store.insert(replacement_test_payment(replacement_txid)).unwrap(); + node.wallet.resolve_broadcast_intents([(original_txid, original_txid, false)]).unwrap(); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_none()); + + node.wallet + .stage_broadcast_resolutions(&[(original_txid, replacement_txid, true)]) + .unwrap(); + let block = replacement_test_confirmation_block(replacement.clone()); + concrete_store.fail_next_write_in(BDK_WALLET_TX_GRAPH_PRIMARY_NAMESPACE); + assert!(node.wallet.inner.lock().unwrap().apply_block(&block, 1).is_err()); + assert!(node + .wallet + .inner + .lock() + .unwrap() + .transaction_confirmations() + .contains_key(&replacement_txid)); + + concrete_store.fail_next_write_in(BDK_WALLET_TX_GRAPH_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.complete_broadcast_transitions(), Err(Error::PersistenceFailed)); + let (_, confirming_intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(confirming_intent.state, BroadcastIntentState::Confirming); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_none()); + drop(node); + + let restarted = replacement_test_node(store); + let (_, accepted_intent) = + restarted.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(accepted_intent.state, BroadcastIntentState::Accepted); + assert_eq!(accepted_intent.transactions.len(), 2); + let payment = restarted.payment(&PaymentId(replacement_txid.to_byte_array())).unwrap(); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed } + if txid == replacement_txid + )); + assert!(restarted.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + } + + #[test] + fn observation_transition_completes_after_restart_without_reapplying_the_replacement() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(31); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(32); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let future_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(60); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(replacement.clone(), future_seen)], Vec::new()) + .unwrap(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + // Simulate a crash after recording the backend observation and before applying its update. + node.wallet.stage_broadcast_resolutions(&[(original_txid, original_txid, false)]).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert!(restarted.wallet.list_pending_broadcasts().unwrap().is_empty()); + let wallet = restarted.wallet.inner.lock().unwrap(); + assert_eq!(wallet.find_tx(original_txid), Some(original.clone())); + assert_eq!(wallet.find_tx(replacement_txid), None); + drop(wallet); + let (_, restored_intent) = + restarted.wallet.find_broadcast_intent_by_active_txid(&original_txid).unwrap(); + assert_eq!(restored_intent.transactions, vec![original, replacement]); + assert_eq!(restored_intent.state, BroadcastIntentState::Accepted); + } + + #[test] + fn legacy_single_transaction_broadcast_intent_is_restored() { + let store = Arc::new(InMemoryStore::new()); + let tx = replacement_test_transaction(7); + let txid = tx.compute_txid(); + let mut bytes = vec![LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION]; + bytes.extend(serialize(&tx)); + KVStoreSync::write( + &*store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &txid.to_string(), + bytes, + ) + .unwrap(); + let dyn_store: Arc = store; + + let node = replacement_test_node(dyn_store); + + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), Some(tx)); + } + + #[test] + fn legacy_resolved_lineage_is_migrated_without_becoming_pending() { + let store = Arc::new(InMemoryStore::new()); + let original = replacement_test_transaction(29); + let replacement = replacement_test_transaction(30); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let transactions = vec![original.clone(), replacement.clone()]; + let mut bytes = vec![LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION]; + bytes.extend(serialize(&(2u32, &transactions))); + KVStoreSync::write( + &*store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &original_txid.to_string(), + bytes, + ) + .unwrap(); + let dyn_store: Arc = store; + + let node = replacement_test_node(dyn_store); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + let (_, intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(intent.transactions, transactions); + assert_eq!(intent.state, BroadcastIntentState::Accepted); + } } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 7cb56ed19a..fe7617d04f 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3063,32 +3063,22 @@ async fn onchain_transaction_evicted_event() { // Sync to detect the unconfirmed transaction node.sync_wallets().unwrap(); - let mut found_received_event = false; - for _ in 0..10 { - if let Some(event) = node.next_event() { - match event { - Event::OnchainTransactionReceived { txid: event_txid, .. } => { - if event_txid == txid { - found_received_event = true; - println!("Received OnchainTransactionReceived event for {}", txid); - node.event_handled().unwrap(); - break; - } - node.event_handled().unwrap(); - }, - _ => { - node.event_handled().unwrap(); - }, - } - } - thread::sleep(Duration::from_millis(100)); - } - + let payment = node.payment(&PaymentId(txid.to_byte_array())).unwrap(); + assert_eq!(payment.direction, PaymentDirection::Outbound); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { txid: payment_txid, status: ConfirmationStatus::Unconfirmed } + if payment_txid == txid + )); assert!( - found_received_event, - "Should have received OnchainTransactionReceived event for transaction {}", + node.onchain_payment().list_pending_broadcasts().unwrap().is_empty(), + "Accepted transaction {} must not remain pending backend reconciliation", txid ); + while node.next_event().is_some() { + node.event_handled().unwrap(); + } // Remove the transaction from bitcoind's mempool to simulate eviction let txid_hex = format!("{:x}", txid); diff --git a/tests/multi_address_types_tests.rs b/tests/multi_address_types_tests.rs index 060716e419..6701599766 100644 --- a/tests/multi_address_types_tests.rs +++ b/tests/multi_address_types_tests.rs @@ -1805,6 +1805,7 @@ mod rbf { use bitcoin::FeeRate; use electrum_client::ElectrumApi; use ldk_node::config::AddressType; + use ldk_node::payment::PaymentKind; use crate::common::{ api_fee_rate, setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource, @@ -1886,7 +1887,6 @@ mod rbf { wait_for_tx(&electrsd.client, initial_txid).await; node.sync_wallets().unwrap(); - std::thread::sleep(std::time::Duration::from_secs(1)); let total_before_rbf = node.list_balances().total_onchain_balance_sats; @@ -1903,6 +1903,18 @@ mod rbf { // Confirm replacement; balance check after confirm avoids sync timing flakiness. confirm_and_sync(&bitcoind, &electrsd, 1, &[&node]).await; + let replacement_history = node.list_payments_with_filter(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { txid, .. } + if txid == initial_txid || txid == rbf_txid + ) + }); + assert_eq!(replacement_history.len(), 1); + assert!(matches!( + replacement_history[0].kind, + PaymentKind::Onchain { txid, .. } if txid == rbf_txid + )); let total_after_rbf = node.list_balances().total_onchain_balance_sats; assert!(