diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abfe1ccac..1bff54de99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,30 @@ -# 0.7.0-rc.66 (Synonym Fork) +# 0.7.0-rc.67 (Synonym Fork) -## Bug Fixes +## Synonym Fork Additions +- Added durable `broadcast_outcome` reconciliation by any RBF-lineage transaction ID with explicit + `Pending`, `Accepted`, and `Abandoned` states. Acceptance-unknown outcomes survive restart and + confirmation until the consumer calls `acknowledge_broadcast_outcome`. +- `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 bindings error type so broadcast failures can expose their + transaction ID. Swift error cases no longer contain the legacy generated `message` associated + value, while fieldless Kotlin and Python 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. -# 0.7.0-rc.64 (Synonym Fork) - -## Bug Fixes - - The Android AAR now ships targeted R8 consumer keep rules for the UniFFI/JNA FFI surface. -# 0.7.0-rc.63 (Synonym Fork) - -## Bug Fixes - - Moved peer persistence to async KV storage so slow writes no longer hold the peer-store lock. - Prevented Electrum runtime self-drop crashes and unbounded shutdown waits. - Scaled Electrum full-scan timeouts for additional wallets with the configured stop gap and batch @@ -82,8 +91,6 @@ and skips the channel manager when one already exists. Read or deserialization failures fail-closed to prevent silent data loss. -## Synonym Fork Additions - - Removed `set_accept_stale_channel_monitors` and the patched Synonym `rust-lightning` branch. Stale channel-monitor mismatches now fail closed with `BuildError::DangerousValue`. Lightning crates come from crates.io `0.2.0` again. diff --git a/Cargo.lock b/Cargo.lock index 5be41f2822..bec108c867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "ldk-node" -version = "0.7.0-rc.66" +version = "0.7.0-rc.67" dependencies = [ "base64 0.22.1", "bdk-wallet-aggregate", @@ -1919,6 +1919,7 @@ dependencies = [ "electrsd", "electrum-client 0.24.1", "esplora-client 0.12.3", + "futures-util", "ldk-node 0.6.2", "libc", "lightning 0.2.5", diff --git a/Cargo.toml b/Cargo.toml index aff8af778d..fcc23105fa 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" @@ -63,6 +63,7 @@ rand = { version = "0.9.2", default-features = false, features = ["std", "thread chrono = { version = "0.4", default-features = false, features = ["clock"] } tokio = { version = "1.37", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros" ] } tokio-util = { version = "0.7.10", default-features = false, features = ["rt"] } +futures-util = "0.3" esplora-client = { version = "0.12", default-features = false, features = ["tokio", "async-https-rustls"] } electrum-client = { version = "0.24.0", default-features = false, features = ["proxy", "use-rustls-ring"] } libc = "0.2" diff --git a/Package.swift b/Package.swift index f8cc852f0e..dd31a02ac6 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 = "549f7a962f7b4262ef77b7e65abd6fe588be87801bee35da6295571c141f183b" 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/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..4519e4dbba 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 @@ -1543,6 +1543,16 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + + + + + + + @@ -2610,6 +2620,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, @@ -2617,6 +2632,11 @@ internal interface UniffiLib : Library { `destinationAddress`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_acknowledge_broadcast_outcome( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit fun uniffi_ldk_node_fn_method_onchainpayment_address_info_for_account_at_index( `ptr`: Pointer?, `addressType`: RustBufferByValue, @@ -2649,6 +2669,11 @@ internal interface UniffiLib : Library { `count`: Int, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_broadcast_outcome( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_bump_fee_by_rbf( `ptr`: Pointer?, `txid`: RustBufferByValue, @@ -2676,6 +2701,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 +2739,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,8 +3500,12 @@ 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_acknowledge_broadcast_outcome( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_type_at_index( @@ -3476,6 +3514,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_address_infos_for_type( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_broadcast_outcome( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_bump_fee_by_rbf( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate( @@ -3484,6 +3524,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 +3540,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,9 +4137,15 @@ 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") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_acknowledge_broadcast_outcome() != 30310.toShort()) { + throw RuntimeException("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.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4108,6 +4158,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_address_infos_for_type() != 3701.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_broadcast_outcome() != 15076.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_bump_fee_by_rbf() != 53877.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4120,6 +4173,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 +4197,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 +8396,23 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { } + /** + * Releases a pending spend only after an independent source proves every lineage member + * absent from both the mempool and chain and no other process can rebroadcast it. + */ + @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 { @@ -8352,6 +8428,23 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + /** + * Removes a terminal outcome after the consumer has durably handled it. + * Fails while the lineage is still active and is idempotent after removal. + */ + @Throws(NodeException::class) + override fun `acknowledgeBroadcastOutcome`(`txid`: Txid) { + callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_acknowledge_broadcast_outcome( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + } + } + @Throws(NodeException::class) override fun `addressInfoForAccountAtIndex`(`addressType`: AddressType, `accountIndex`: kotlin.UInt, `keychain`: KeychainKind, `index`: kotlin.UInt): AddressInfo { return FfiConverterTypeAddressInfo.lift(callWithPointer { @@ -8416,6 +8509,28 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + /** + * Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + * Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + */ + @Throws(NodeException::class) + override fun `broadcastOutcome`(`txid`: Txid): BroadcastOutcome? { + return FfiConverterOptionalTypeBroadcastOutcome.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_broadcast_outcome( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + }) + } + + /** + * Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ @Throws(NodeException::class) override fun `bumpFeeByRbf`(`txid`: Txid, `feeRate`: FeeRate): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -8475,6 +8590,21 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + /** + * Lists unresolved broadcasts and every transaction in each RBF lineage. + */ + @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 +8695,23 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + /** + * Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + * Do not create another spend for the same payment while its pending entry remains. + */ + @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 { @@ -8610,6 +8757,11 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + /** + * Sends the available balance and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ @Throws(NodeException::class) override fun `sendAllToAddress`(`address`: Address, `retainReserve`: kotlin.Boolean, `feeRate`: FeeRate?): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -8625,6 +8777,11 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + /** + * Sends an exact amount and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ @Throws(NodeException::class) override fun `sendToAddress`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -9635,6 +9792,31 @@ object FfiConverterTypeBestBlock: FfiConverterRustBuffer { +object FfiConverterTypeBroadcastOutcome: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BroadcastOutcome { + return BroadcastOutcome( + FfiConverterTypeBroadcastOutcomeStatus.read(buf), + FfiConverterTypeTxid.read(buf), + FfiConverterSequenceTypeTxid.read(buf), + ) + } + + override fun allocationSize(value: BroadcastOutcome) = ( + FfiConverterTypeBroadcastOutcomeStatus.allocationSize(value.`status`) + + FfiConverterTypeTxid.allocationSize(value.`txid`) + + FfiConverterSequenceTypeTxid.allocationSize(value.`lineage`) + ) + + override fun write(value: BroadcastOutcome, buf: ByteBuffer) { + FfiConverterTypeBroadcastOutcomeStatus.write(value.`status`, buf) + FfiConverterTypeTxid.write(value.`txid`, buf) + FfiConverterSequenceTypeTxid.write(value.`lineage`, buf) + } +} + + + + object FfiConverterTypeChannelConfig: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChannelConfig { return ChannelConfig( @@ -10501,6 +10683,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( @@ -10913,6 +11117,24 @@ object FfiConverterTypeBolt11InvoiceDescription : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + BroadcastOutcomeStatus.entries[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: BroadcastOutcomeStatus) = 4UL + + override fun write(value: BroadcastOutcomeStatus, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + object BuildExceptionErrorHandler : UniffiRustCallStatusErrorHandler { override fun lift(errorBuf: RustBufferByValue): BuildException = FfiConverterTypeBuildError.lift(errorBuf) } @@ -12132,80 +12354,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 +13009,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 */ } } } @@ -13235,6 +13782,35 @@ object FfiConverterOptionalTypeBackgroundSyncConfig: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): BroadcastOutcome? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterTypeBroadcastOutcome.read(buf) + } + + override fun allocationSize(value: BroadcastOutcome?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterTypeBroadcastOutcome.allocationSize(value) + } + } + + override fun write(value: BroadcastOutcome?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterTypeBroadcastOutcome.write(value, buf) + } + } +} + + + + object FfiConverterOptionalTypeChannelConfig: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ChannelConfig? { if (buf.get().toInt() == 0) { @@ -14587,6 +15163,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..520e4cbe8d 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,9 +593,23 @@ interface OfferInterface { interface OnchainPaymentInterface { + /** + * Releases a pending spend only after an independent source proves every lineage member + * absent from both the mempool and chain and no other process can rebroadcast it. + */ + @Throws(NodeException::class) + fun `abandonPendingBroadcast`(`txid`: Txid) + @Throws(NodeException::class) fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid + /** + * Removes a terminal outcome after the consumer has durably handled it. + * Fails while the lineage is still active and is idempotent after removal. + */ + @Throws(NodeException::class) + fun `acknowledgeBroadcastOutcome`(`txid`: Txid) + @Throws(NodeException::class) fun `addressInfoForAccountAtIndex`(`addressType`: AddressType, `accountIndex`: kotlin.UInt, `keychain`: KeychainKind, `index`: kotlin.UInt): AddressInfo @@ -608,6 +622,18 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `addressInfosForType`(`addressType`: AddressType, `keychain`: KeychainKind, `startIndex`: kotlin.UInt, `count`: kotlin.UInt): List + /** + * Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + * Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + */ + @Throws(NodeException::class) + fun `broadcastOutcome`(`txid`: Txid): BroadcastOutcome? + + /** + * Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ @Throws(NodeException::class) fun `bumpFeeByRbf`(`txid`: Txid, `feeRate`: FeeRate): Txid @@ -620,6 +646,12 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong + /** + * Lists unresolved broadcasts and every transaction in each RBF lineage. + */ + @Throws(NodeException::class) + fun `listPendingBroadcasts`(): List + @Throws(NodeException::class) fun `listSpendableOutputs`(): List @@ -641,6 +673,13 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `newAddressInfoForType`(`addressType`: AddressType): AddressInfo + /** + * Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + * Do not create another spend for the same payment while its pending entry remains. + */ + @Throws(NodeException::class) + fun `rebroadcastTransaction`(`txid`: Txid): Txid + @Throws(NodeException::class) fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) @@ -650,9 +689,19 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `selectUtxosWithAlgorithm`(`targetAmountSats`: kotlin.ULong, `feeRate`: FeeRate?, `algorithm`: CoinSelectionAlgorithm, `utxos`: List?): List + /** + * Sends the available balance and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ @Throws(NodeException::class) fun `sendAllToAddress`(`address`: Address, `retainReserve`: kotlin.Boolean, `feeRate`: FeeRate?): Txid + /** + * Sends an exact amount and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ @Throws(NodeException::class) fun `sendToAddress`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): Txid @@ -804,6 +853,17 @@ data class BestBlock ( +@kotlinx.serialization.Serializable +data class BroadcastOutcome ( + val `status`: BroadcastOutcomeStatus, + val `txid`: Txid, + val `lineage`: List +) { + companion object +} + + + @kotlinx.serialization.Serializable data class ChannelConfig ( val `forwardingFeeProportionalMillionths`: kotlin.UInt, @@ -1197,6 +1257,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, @@ -1397,6 +1467,21 @@ sealed class Bolt11InvoiceDescription { +@kotlinx.serialization.Serializable +enum class BroadcastOutcomeStatus { + + PENDING, + ACCEPTED, + ABANDONED; + companion object +} + + + + + + + sealed class BuildException(message: String): kotlin.Exception(message) { class InvalidSeedBytes(message: String) : BuildException(message) @@ -1887,143 +1972,457 @@ 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) + /** + * The backend conclusively rejected the transaction. + */ + class OnchainTxBroadcastRejected( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class AddressTypeNotMonitored(message: String) : NodeException(message) + /** + * Dispatch occurred, but backend acceptance is unknown. Do not create a fresh spend; + * reconcile or rebroadcast this exact transaction ID. + */ + class OnchainTxBroadcastFailed( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class OnchainWalletAccountNotRegistered(message: String) : NodeException(message) + /** + * Dispatch occurred, but backend acceptance is unknown after the timeout. Do not create a + * fresh spend; reconcile or rebroadcast this exact transaction ID. + */ + class OnchainTxBroadcastTimeout( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class InvalidSeedBytes(message: String) : NodeException(message) + /** + * The transaction was conclusively not dispatched to the backend. + */ + class OnchainTxBroadcastNotDispatched( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } } @@ -2409,6 +2808,10 @@ 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/ldk_node.udl b/bindings/ldk_node.udl index 9cdcdc85b4..e3e6964701 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -382,10 +382,38 @@ interface OnchainPayment { sequence list_spendable_outputs(); [Throws=NodeError] sequence select_utxos_with_algorithm(u64 target_amount_sats, FeeRate? fee_rate, CoinSelectionAlgorithm algorithm, sequence? utxos); + /// Sends an exact amount and waits for the configured backend's broadcast result. + /// `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + /// reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. [Throws=NodeError] Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate, sequence? utxos_to_spend); + /// Sends the available balance and waits for the configured backend's broadcast result. + /// `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + /// reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. [Throws=NodeError] Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); + /// Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + /// Do not create another spend for the same payment while its pending entry remains. + [Throws=NodeError] + Txid rebroadcast_transaction([ByRef]Txid txid); + /// Lists unresolved broadcasts and every transaction in each RBF lineage. + [Throws=NodeError] + sequence list_pending_broadcasts(); + /// Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + /// Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + [Throws=NodeError] + BroadcastOutcome? broadcast_outcome([ByRef]Txid txid); + /// Removes a terminal outcome after the consumer has durably handled it. + /// Fails while the lineage is still active and is idempotent after removal. + [Throws=NodeError] + void acknowledge_broadcast_outcome([ByRef]Txid txid); + /// Releases a pending spend only after an independent source proves every lineage member + /// absent from both the mempool and chain and no other process can rebroadcast it. + [Throws=NodeError] + void abandon_pending_broadcast([ByRef]Txid txid); + /// Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + /// `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + /// reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. [Throws=NodeError] Txid bump_fee_by_rbf([ByRef]Txid txid, FeeRate fee_rate); [Throws=NodeError] @@ -405,6 +433,23 @@ enum CoinSelectionAlgorithm { "SingleRandomDraw", }; +dictionary PendingBroadcastInfo { + Txid txid; + sequence lineage; +}; + +enum BroadcastOutcomeStatus { + "Pending", + "Accepted", + "Abandoned", +}; + +dictionary BroadcastOutcome { + BroadcastOutcomeStatus status; + Txid txid; + sequence lineage; +}; + dictionary SpendableUtxo { OutPoint outpoint; u64 value_sats; @@ -435,75 +480,85 @@ 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(); + /// The backend conclusively rejected the transaction. + OnchainTxBroadcastRejected(Txid txid); + /// Dispatch occurred, but backend acceptance is unknown. Do not create a fresh spend; + /// reconcile or rebroadcast this exact transaction ID. + OnchainTxBroadcastFailed(Txid txid); + /// Dispatch occurred, but backend acceptance is unknown after the timeout. Do not create a + /// fresh spend; reconcile or rebroadcast this exact transaction ID. + OnchainTxBroadcastTimeout(Txid txid); + /// The transaction was conclusively not dispatched to the backend. + 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..67c3578a52 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -805,8 +805,12 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_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_acknowledge_broadcast_outcome() != 30310: + 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: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_type_at_index() != 42692: @@ -815,6 +819,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_address_infos_for_type() != 3701: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_broadcast_outcome() != 15076: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_bump_fee_by_rbf() != 53877: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_cpfp_fee_rate() != 32879: @@ -823,6 +829,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 +845,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 +2259,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, @@ -2257,6 +2273,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_acknowledge_broadcast_outcome.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_acknowledge_broadcast_outcome.restype = None _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_address_info_for_account_at_index.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -2293,6 +2315,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_address_infos_for_type.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_broadcast_outcome.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_broadcast_outcome.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_bump_fee_by_rbf.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -2324,6 +2352,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 +2398,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,9 +3428,15 @@ 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 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_acknowledge_broadcast_outcome.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_acknowledge_broadcast_outcome.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index.restype = ctypes.c_uint16 @@ -3404,6 +3449,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_address_infos_for_type.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_address_infos_for_type.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_broadcast_outcome.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_broadcast_outcome.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_bump_fee_by_rbf.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_bump_fee_by_rbf.restype = ctypes.c_uint16 @@ -3416,6 +3464,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 +3488,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,7 +6939,21 @@ def write(cls, value: OfferProtocol, buf: _UniffiRustBuffer): class OnchainPaymentProtocol(typing.Protocol): + def abandon_pending_broadcast(self, txid: "Txid"): + """ + Releases a pending spend only after an independent source proves every lineage member + absent from both the mempool and chain and no other process can rebroadcast it. + """ + + raise NotImplementedError def accelerate_by_cpfp(self, txid: "Txid",fee_rate: "typing.Optional[FeeRate]",destination_address: "typing.Optional[Address]"): + raise NotImplementedError + def acknowledge_broadcast_outcome(self, txid: "Txid"): + """ + Removes a terminal outcome after the consumer has durably handled it. + Fails while the lineage is still active and is idempotent after removal. + """ + raise NotImplementedError def address_info_for_account_at_index(self, address_type: "AddressType",account_index: "int",keychain: "KeychainKind",index: "int"): raise NotImplementedError @@ -6894,14 +6962,33 @@ def address_info_for_type_at_index(self, address_type: "AddressType",keychain: " def address_infos_for_account(self, address_type: "AddressType",account_index: "int",keychain: "KeychainKind",start_index: "int",count: "int"): raise NotImplementedError def address_infos_for_type(self, address_type: "AddressType",keychain: "KeychainKind",start_index: "int",count: "int"): + raise NotImplementedError + def broadcast_outcome(self, txid: "Txid"): + """ + Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + """ + raise NotImplementedError def bump_fee_by_rbf(self, txid: "Txid",fee_rate: "FeeRate"): + """ + Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + """ + raise NotImplementedError def calculate_cpfp_fee_rate(self, parent_txid: "Txid",urgent: "bool"): raise NotImplementedError def calculate_send_all_fee(self, address: "Address",retain_reserves: "bool",fee_rate: "typing.Optional[FeeRate]"): 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, ): + """ + Lists unresolved broadcasts and every transaction in each RBF lineage. + """ + raise NotImplementedError def list_spendable_outputs(self, ): raise NotImplementedError @@ -6916,6 +7003,13 @@ def new_address_info(self, ): def new_address_info_for_account(self, address_type: "AddressType",account_index: "int"): raise NotImplementedError def new_address_info_for_type(self, address_type: "AddressType"): + raise NotImplementedError + def rebroadcast_transaction(self, txid: "Txid"): + """ + Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + Do not create another spend for the same payment while its pending entry remains. + """ + raise NotImplementedError def reveal_receive_addresses_to(self, address_type: "AddressType",index: "int"): raise NotImplementedError @@ -6924,8 +7018,20 @@ def reveal_receive_addresses_to_account(self, address_type: "AddressType",accoun def select_utxos_with_algorithm(self, target_amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",algorithm: "CoinSelectionAlgorithm",utxos: "typing.Optional[typing.List[SpendableUtxo]]"): raise NotImplementedError def send_all_to_address(self, address: "Address",retain_reserve: "bool",fee_rate: "typing.Optional[FeeRate]"): + """ + Sends the available balance and waits for the configured backend's broadcast result. + `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + """ + raise NotImplementedError def send_to_address(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]"): + """ + Sends an exact amount and waits for the configured backend's broadcast result. + `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + """ + raise NotImplementedError @@ -6954,6 +7060,22 @@ def _make_instance_(cls, pointer): return inst + def abandon_pending_broadcast(self, txid: "Txid") -> None: + """ + Releases a pending spend only after an independent source proves every lineage member + absent from both the mempool and chain and no other process can rebroadcast it. + """ + + _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) @@ -6972,6 +7094,22 @@ def accelerate_by_cpfp(self, txid: "Txid",fee_rate: "typing.Optional[FeeRate]",d + def acknowledge_broadcast_outcome(self, txid: "Txid") -> None: + """ + Removes a terminal outcome after the consumer has durably handled it. + Fails while the lineage is still active and is idempotent after removal. + """ + + _UniffiConverterTypeTxid.check_lower(txid) + + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_acknowledge_broadcast_outcome,self._uniffi_clone_pointer(), + _UniffiConverterTypeTxid.lower(txid)) + + + + + + def address_info_for_account_at_index(self, address_type: "AddressType",account_index: "int",keychain: "KeychainKind",index: "int") -> "AddressInfo": _UniffiConverterTypeAddressType.check_lower(address_type) @@ -7056,7 +7194,30 @@ def address_infos_for_type(self, address_type: "AddressType",keychain: "Keychain + def broadcast_outcome(self, txid: "Txid") -> "typing.Optional[BroadcastOutcome]": + """ + Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + """ + + _UniffiConverterTypeTxid.check_lower(txid) + + return _UniffiConverterOptionalTypeBroadcastOutcome.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_broadcast_outcome,self._uniffi_clone_pointer(), + _UniffiConverterTypeTxid.lower(txid)) + ) + + + + + def bump_fee_by_rbf(self, txid: "Txid",fee_rate: "FeeRate") -> "Txid": + """ + Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + """ + _UniffiConverterTypeTxid.check_lower(txid) _UniffiConverterTypeFeeRate.check_lower(fee_rate) @@ -7125,6 +7286,19 @@ def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "t + def list_pending_broadcasts(self, ) -> "typing.List[PendingBroadcastInfo]": + """ + Lists unresolved broadcasts and every transaction in each RBF lineage. + """ + + 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 +7380,23 @@ def new_address_info_for_type(self, address_type: "AddressType") -> "AddressInfo + def rebroadcast_transaction(self, txid: "Txid") -> "Txid": + """ + Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + Do not create another spend for the same payment while its pending entry remains. + """ + + _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) @@ -7259,6 +7450,12 @@ def select_utxos_with_algorithm(self, target_amount_sats: "int",fee_rate: "typin def send_all_to_address(self, address: "Address",retain_reserve: "bool",fee_rate: "typing.Optional[FeeRate]") -> "Txid": + """ + Sends the available balance and waits for the configured backend's broadcast result. + `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + """ + _UniffiConverterTypeAddress.check_lower(address) _UniffiConverterBool.check_lower(retain_reserve) @@ -7277,6 +7474,12 @@ def send_all_to_address(self, address: "Address",retain_reserve: "bool",fee_rate def send_to_address(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]") -> "Txid": + """ + Sends an exact amount and waits for the configured backend's broadcast result. + `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + """ + _UniffiConverterTypeAddress.check_lower(address) _UniffiConverterUInt64.check_lower(amount_sats) @@ -8140,6 +8343,49 @@ def write(value, buf): _UniffiConverterUInt32.write(value.height, buf) +class BroadcastOutcome: + status: "BroadcastOutcomeStatus" + txid: "Txid" + lineage: "typing.List[Txid]" + def __init__(self, *, status: "BroadcastOutcomeStatus", txid: "Txid", lineage: "typing.List[Txid]"): + self.status = status + self.txid = txid + self.lineage = lineage + + def __str__(self): + return "BroadcastOutcome(status={}, txid={}, lineage={})".format(self.status, self.txid, self.lineage) + + def __eq__(self, other): + if self.status != other.status: + return False + if self.txid != other.txid: + return False + if self.lineage != other.lineage: + return False + return True + +class _UniffiConverterTypeBroadcastOutcome(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return BroadcastOutcome( + status=_UniffiConverterTypeBroadcastOutcomeStatus.read(buf), + txid=_UniffiConverterTypeTxid.read(buf), + lineage=_UniffiConverterSequenceTypeTxid.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterTypeBroadcastOutcomeStatus.check_lower(value.status) + _UniffiConverterTypeTxid.check_lower(value.txid) + _UniffiConverterSequenceTypeTxid.check_lower(value.lineage) + + @staticmethod + def write(value, buf): + _UniffiConverterTypeBroadcastOutcomeStatus.write(value.status, buf) + _UniffiConverterTypeTxid.write(value.txid, buf) + _UniffiConverterSequenceTypeTxid.write(value.lineage, buf) + + class ChannelConfig: forwarding_fee_proportional_millionths: "int" forwarding_fee_base_msat: "int" @@ -9762,6 +10008,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" @@ -10553,6 +10835,52 @@ def write(value, buf): + + + +class BroadcastOutcomeStatus(enum.Enum): + PENDING = 0 + + ACCEPTED = 1 + + ABANDONED = 2 + + + +class _UniffiConverterTypeBroadcastOutcomeStatus(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return BroadcastOutcomeStatus.PENDING + if variant == 2: + return BroadcastOutcomeStatus.ACCEPTED + if variant == 3: + return BroadcastOutcomeStatus.ABANDONED + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == BroadcastOutcomeStatus.PENDING: + return + if value == BroadcastOutcomeStatus.ACCEPTED: + return + if value == BroadcastOutcomeStatus.ABANDONED: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == BroadcastOutcomeStatus.PENDING: + buf.write_i32(1) + if value == BroadcastOutcomeStatus.ACCEPTED: + buf.write_i32(2) + if value == BroadcastOutcomeStatus.ABANDONED: + buf.write_i32(3) + + + + # BuildError # We want to define each variant as a nested class that's also a subclass, # which is tricky in Python. To accomplish this we're going to create each @@ -13181,345 +13509,539 @@ 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): + """ + The backend conclusively rejected the transaction. + """ + + 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): + """ + Dispatch occurred, but backend acceptance is unknown. Do not create a fresh spend; + reconcile or rebroadcast this exact transaction ID. + """ + + 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): + """ + Dispatch occurred, but backend acceptance is unknown after the timeout. Do not create a + fresh spend; reconcile or rebroadcast this exact transaction ID. + """ + + 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): + """ + The transaction was conclusively not dispatched to the backend. + """ + + 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 +14053,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 +14411,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 +14562,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) @@ -15439,6 +15933,33 @@ def read(cls, buf): +class _UniffiConverterOptionalTypeBroadcastOutcome(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiConverterTypeBroadcastOutcome.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiConverterTypeBroadcastOutcome.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiConverterTypeBroadcastOutcome.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + + + class _UniffiConverterOptionalTypeChannelConfig(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -16715,6 +17236,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): @@ -17655,6 +18201,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem "AsyncPaymentsRole", "BalanceSource", "Bolt11InvoiceDescription", + "BroadcastOutcomeStatus", "BuildError", "ClosureReason", "CoinSelectionAlgorithm", @@ -17684,6 +18231,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem "BackgroundSyncConfig", "BalanceDetails", "BestBlock", + "BroadcastOutcome", "ChannelConfig", "ChannelDataMigration", "ChannelDetails", @@ -17710,6 +18258,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..f434414e97 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3355,8 +3355,20 @@ public func FfiConverterTypeOffer_lower(_ value: Offer) -> UnsafeMutableRawPoint } public protocol OnchainPaymentProtocol: AnyObject { + /** + * Releases a pending spend only after an independent source proves every lineage member + * absent from both the mempool and chain and no other process can rebroadcast it. + */ + func abandonPendingBroadcast(txid: Txid) throws + func accelerateByCpfp(txid: Txid, feeRate: FeeRate?, destinationAddress: Address?) throws -> Txid + /** + * Removes a terminal outcome after the consumer has durably handled it. + * Fails while the lineage is still active and is idempotent after removal. + */ + func acknowledgeBroadcastOutcome(txid: Txid) throws + func addressInfoForAccountAtIndex(addressType: AddressType, accountIndex: UInt32, keychain: KeychainKind, index: UInt32) throws -> AddressInfo func addressInfoForTypeAtIndex(addressType: AddressType, keychain: KeychainKind, index: UInt32) throws -> AddressInfo @@ -3365,6 +3377,17 @@ public protocol OnchainPaymentProtocol: AnyObject { func addressInfosForType(addressType: AddressType, keychain: KeychainKind, startIndex: UInt32, count: UInt32) throws -> [AddressInfo] + /** + * Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + * Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + */ + func broadcastOutcome(txid: Txid) throws -> BroadcastOutcome? + + /** + * Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ func bumpFeeByRbf(txid: Txid, feeRate: FeeRate) throws -> Txid func calculateCpfpFeeRate(parentTxid: Txid, urgent: Bool) throws -> FeeRate @@ -3373,6 +3396,11 @@ public protocol OnchainPaymentProtocol: AnyObject { func calculateTotalFee(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> UInt64 + /** + * Lists unresolved broadcasts and every transaction in each RBF lineage. + */ + func listPendingBroadcasts() throws -> [PendingBroadcastInfo] + func listSpendableOutputs() throws -> [SpendableUtxo] func newAddress() throws -> Address @@ -3387,14 +3415,30 @@ public protocol OnchainPaymentProtocol: AnyObject { func newAddressInfoForType(addressType: AddressType) throws -> AddressInfo + /** + * Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + * Do not create another spend for the same payment while its pending entry remains. + */ + func rebroadcastTransaction(txid: Txid) throws -> Txid + func revealReceiveAddressesTo(addressType: AddressType, index: UInt32) throws func revealReceiveAddressesToAccount(addressType: AddressType, accountIndex: UInt32, index: UInt32) throws func selectUtxosWithAlgorithm(targetAmountSats: UInt64, feeRate: FeeRate?, algorithm: CoinSelectionAlgorithm, utxos: [SpendableUtxo]?) throws -> [SpendableUtxo] + /** + * Sends the available balance and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ func sendAllToAddress(address: Address, retainReserve: Bool, feeRate: FeeRate?) throws -> Txid + /** + * Sends an exact amount and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ func sendToAddress(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> Txid } @@ -3447,6 +3491,17 @@ open class OnchainPayment: try! rustCall { uniffi_ldk_node_fn_free_onchainpayment(pointer, $0) } } + /** + * Releases a pending spend only after an independent source proves every lineage member + * absent from both the mempool and chain and no other process can rebroadcast it. + */ + 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(), @@ -3456,6 +3511,17 @@ open class OnchainPayment: }) } + /** + * Removes a terminal outcome after the consumer has durably handled it. + * Fails while the lineage is still active and is idempotent after removal. + */ + open func acknowledgeBroadcastOutcome(txid: Txid) throws { + try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_acknowledge_broadcast_outcome(self.uniffiClonePointer(), + FfiConverterTypeTxid.lower(txid), $0) + } + } + open func addressInfoForAccountAtIndex(addressType: AddressType, accountIndex: UInt32, keychain: KeychainKind, index: UInt32) throws -> AddressInfo { return try FfiConverterTypeAddressInfo.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_address_info_for_account_at_index(self.uniffiClonePointer(), @@ -3496,6 +3562,22 @@ open class OnchainPayment: }) } + /** + * Returns a durable Pending, Accepted, or Abandoned outcome by any RBF-lineage txid. + * Only Accepted proves backend acceptance. A null result is unknown or acknowledged. + */ + open func broadcastOutcome(txid: Txid) throws -> BroadcastOutcome? { + return try FfiConverterOptionTypeBroadcastOutcome.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_broadcast_outcome(self.uniffiClonePointer(), + FfiConverterTypeTxid.lower(txid), $0) + }) + } + + /** + * Replaces an unconfirmed transaction and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ open func bumpFeeByRbf(txid: Txid, feeRate: FeeRate) throws -> Txid { return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_bump_fee_by_rbf(self.uniffiClonePointer(), @@ -3531,6 +3613,15 @@ open class OnchainPayment: }) } + /** + * Lists unresolved broadcasts and every transaction in each RBF lineage. + */ + 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 +3670,17 @@ open class OnchainPayment: }) } + /** + * Rebroadcasts the exact persisted transaction for an acceptance-unknown send. + * Do not create another spend for the same payment while its pending entry remains. + */ + 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(), @@ -3606,6 +3708,11 @@ open class OnchainPayment: }) } + /** + * Sends the available balance and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ open func sendAllToAddress(address: Address, retainReserve: Bool, feeRate: FeeRate?) throws -> Txid { return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_send_all_to_address(self.uniffiClonePointer(), @@ -3615,6 +3722,11 @@ open class OnchainPayment: }) } + /** + * Sends an exact amount and waits for the configured backend's broadcast result. + * `OnchainTxBroadcastFailed` and `OnchainTxBroadcastTimeout` mean acceptance is unknown: + * reconcile or rebroadcast the returned transaction ID and do not create a fresh spend. + */ open func sendToAddress(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> Txid { return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_send_to_address(self.uniffiClonePointer(), @@ -4697,6 +4809,75 @@ public func FfiConverterTypeBestBlock_lower(_ value: BestBlock) -> RustBuffer { return FfiConverterTypeBestBlock.lower(value) } +public struct BroadcastOutcome { + public var status: BroadcastOutcomeStatus + public var txid: Txid + public var lineage: [Txid] + + /// Default memberwise initializers are never public by default, so we + /// declare one manually. + public init(status: BroadcastOutcomeStatus, txid: Txid, lineage: [Txid]) { + self.status = status + self.txid = txid + self.lineage = lineage + } +} + +extension BroadcastOutcome: Equatable, Hashable { + public static func == (lhs: BroadcastOutcome, rhs: BroadcastOutcome) -> Bool { + if lhs.status != rhs.status { + return false + } + 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(status) + hasher.combine(txid) + hasher.combine(lineage) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypeBroadcastOutcome: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BroadcastOutcome { + return + try BroadcastOutcome( + status: FfiConverterTypeBroadcastOutcomeStatus.read(from: &buf), + txid: FfiConverterTypeTxid.read(from: &buf), + lineage: FfiConverterSequenceTypeTxid.read(from: &buf) + ) + } + + public static func write(_ value: BroadcastOutcome, into buf: inout [UInt8]) { + FfiConverterTypeBroadcastOutcomeStatus.write(value.status, into: &buf) + FfiConverterTypeTxid.write(value.txid, into: &buf) + FfiConverterSequenceTypeTxid.write(value.lineage, into: &buf) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeBroadcastOutcome_lift(_ buf: RustBuffer) throws -> BroadcastOutcome { + return try FfiConverterTypeBroadcastOutcome.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeBroadcastOutcome_lower(_ value: BroadcastOutcome) -> RustBuffer { + return FfiConverterTypeBroadcastOutcome.lower(value) +} + public struct ChannelConfig { public var forwardingFeeProportionalMillionths: UInt32 public var forwardingFeeBaseMsat: UInt32 @@ -6955,6 +7136,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 @@ -8036,6 +8278,64 @@ public func FfiConverterTypeBolt11InvoiceDescription_lower(_ value: Bolt11Invoic extension Bolt11InvoiceDescription: Equatable, Hashable {} +// Note that we don't yet support `indirect` for enums. +// See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. + +public enum BroadcastOutcomeStatus { + case pending + case accepted + case abandoned +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypeBroadcastOutcomeStatus: FfiConverterRustBuffer { + typealias SwiftType = BroadcastOutcomeStatus + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> BroadcastOutcomeStatus { + let variant: Int32 = try readInt(&buf) + switch variant { + case 1: return .pending + + case 2: return .accepted + + case 3: return .abandoned + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: BroadcastOutcomeStatus, into buf: inout [UInt8]) { + switch value { + case .pending: + writeInt(&buf, Int32(1)) + + case .accepted: + writeInt(&buf, Int32(2)) + + case .abandoned: + writeInt(&buf, Int32(3)) + } + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeBroadcastOutcomeStatus_lift(_ buf: RustBuffer) throws -> BroadcastOutcomeStatus { + return try FfiConverterTypeBroadcastOutcomeStatus.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypeBroadcastOutcomeStatus_lower(_ value: BroadcastOutcomeStatus) -> RustBuffer { + return FfiConverterTypeBroadcastOutcomeStatus.lower(value) +} + +extension BroadcastOutcomeStatus: Equatable, Hashable {} + public enum BuildError { case InvalidSeedBytes(message: String) @@ -9178,566 +9478,408 @@ public func FfiConverterTypeNetwork_lower(_ value: Network) -> RustBuffer { extension Network: Equatable, Hashable {} public enum NodeError { - case AlreadyRunning(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 + /** + * The backend conclusively rejected the transaction. + */ + case OnchainTxBroadcastRejected(txid: Txid) + /** + * Dispatch occurred, but backend acceptance is unknown. Do not create a fresh spend; + * reconcile or rebroadcast this exact transaction ID. + */ + case OnchainTxBroadcastFailed(txid: Txid) + /** + * Dispatch occurred, but backend acceptance is unknown after the timeout. Do not create a + * fresh spend; reconcile or rebroadcast this exact transaction ID. + */ + case OnchainTxBroadcastTimeout(txid: Txid) + /** + * The transaction was conclusively not dispatched to the backend. + */ + case OnchainTxBroadcastNotDispatched(txid: Txid) +} - case NotRunning(message: String) +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypeNodeError: FfiConverterRustBuffer { + typealias SwiftType = NodeError - case OnchainTxCreationFailed(message: String) + 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 .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) + ) + default: throw UniffiInternalError.unexpectedEnumCase + } + } - case ConnectionFailed(message: String) + public static func write(_ value: NodeError, into buf: inout [UInt8]) { + switch value { + case .AlreadyRunning: + writeInt(&buf, Int32(1)) - case InvoiceCreationFailed(message: String) + case .NotRunning: + writeInt(&buf, Int32(2)) - case InvoiceRequestCreationFailed(message: String) + case .OnchainTxCreationFailed: + writeInt(&buf, Int32(3)) - case OfferCreationFailed(message: String) + case .ConnectionFailed: + writeInt(&buf, Int32(4)) - case RefundCreationFailed(message: String) + case .InvoiceCreationFailed: + writeInt(&buf, Int32(5)) - case PaymentSendingFailed(message: String) + case .InvoiceRequestCreationFailed: + writeInt(&buf, Int32(6)) - case InvalidCustomTlvs(message: String) + case .OfferCreationFailed: + writeInt(&buf, Int32(7)) - case ProbeSendingFailed(message: String) + case .RefundCreationFailed: + writeInt(&buf, Int32(8)) - case RouteNotFound(message: String) + case .PaymentSendingFailed: + writeInt(&buf, Int32(9)) - case ChannelCreationFailed(message: String) + case .InvalidCustomTlvs: + writeInt(&buf, Int32(10)) - case ChannelClosingFailed(message: String) + case .ProbeSendingFailed: + writeInt(&buf, Int32(11)) - case ChannelSplicingFailed(message: String) + case .RouteNotFound: + writeInt(&buf, Int32(12)) - case ChannelConfigUpdateFailed(message: String) + case .ChannelCreationFailed: + writeInt(&buf, Int32(13)) - case PersistenceFailed(message: String) + case .ChannelClosingFailed: + writeInt(&buf, Int32(14)) - case FeerateEstimationUpdateFailed(message: String) + case .ChannelSplicingFailed: + writeInt(&buf, Int32(15)) - case FeerateEstimationUpdateTimeout(message: String) + case .ChannelConfigUpdateFailed: + writeInt(&buf, Int32(16)) - case WalletOperationFailed(message: String) + case .PersistenceFailed: + writeInt(&buf, Int32(17)) - case WalletOperationTimeout(message: String) + case .FeerateEstimationUpdateFailed: + writeInt(&buf, Int32(18)) - case OnchainTxSigningFailed(message: String) + case .FeerateEstimationUpdateTimeout: + writeInt(&buf, Int32(19)) - case TxSyncFailed(message: String) + case .WalletOperationFailed: + writeInt(&buf, Int32(20)) - case TxSyncTimeout(message: String) + case .WalletOperationTimeout: + writeInt(&buf, Int32(21)) - case GossipUpdateFailed(message: String) + case .OnchainTxSigningFailed: + writeInt(&buf, Int32(22)) - case GossipUpdateTimeout(message: String) + case .TxSyncFailed: + writeInt(&buf, Int32(23)) - case LiquidityRequestFailed(message: String) + case .TxSyncTimeout: + writeInt(&buf, Int32(24)) - case UriParameterParsingFailed(message: String) + case .GossipUpdateFailed: + writeInt(&buf, Int32(25)) - case InvalidAddress(message: String) + case .GossipUpdateTimeout: + writeInt(&buf, Int32(26)) - case InvalidSocketAddress(message: String) + case .LiquidityRequestFailed: + writeInt(&buf, Int32(27)) - case InvalidPublicKey(message: String) + case .UriParameterParsingFailed: + writeInt(&buf, Int32(28)) - case InvalidSecretKey(message: String) + case .InvalidAddress: + writeInt(&buf, Int32(29)) - case InvalidOfferId(message: String) + case .InvalidSocketAddress: + writeInt(&buf, Int32(30)) - case InvalidNodeId(message: String) + case .InvalidPublicKey: + writeInt(&buf, Int32(31)) - case InvalidPaymentId(message: String) + case .InvalidSecretKey: + writeInt(&buf, Int32(32)) - case InvalidPaymentHash(message: String) + case .InvalidOfferId: + writeInt(&buf, Int32(33)) - case InvalidPaymentPreimage(message: String) + case .InvalidNodeId: + writeInt(&buf, Int32(34)) - case InvalidPaymentSecret(message: String) + case .InvalidPaymentId: + writeInt(&buf, Int32(35)) - case InvalidAmount(message: String) + case .InvalidPaymentHash: + writeInt(&buf, Int32(36)) - case InvalidInvoice(message: String) + case .InvalidPaymentPreimage: + writeInt(&buf, Int32(37)) - case InvalidOffer(message: String) + case .InvalidPaymentSecret: + writeInt(&buf, Int32(38)) - case InvalidRefund(message: String) + case .InvalidAmount: + writeInt(&buf, Int32(39)) - case InvalidChannelId(message: String) + case .InvalidInvoice: + writeInt(&buf, Int32(40)) - case InvalidNetwork(message: String) + case .InvalidOffer: + writeInt(&buf, Int32(41)) - case InvalidUri(message: String) + case .InvalidRefund: + writeInt(&buf, Int32(42)) - case InvalidQuantity(message: String) + case .InvalidChannelId: + writeInt(&buf, Int32(43)) - case InvalidNodeAlias(message: String) + case .InvalidNetwork: + writeInt(&buf, Int32(44)) - case InvalidDateTime(message: String) + case .InvalidUri: + writeInt(&buf, Int32(45)) - case InvalidFeeRate(message: String) + case .InvalidQuantity: + writeInt(&buf, Int32(46)) - case DuplicatePayment(message: String) + case .InvalidNodeAlias: + writeInt(&buf, Int32(47)) - case UnsupportedCurrency(message: String) + case .InvalidDateTime: + writeInt(&buf, Int32(48)) - case InsufficientFunds(message: String) + case .InvalidFeeRate: + writeInt(&buf, Int32(49)) - case LiquiditySourceUnavailable(message: String) + case .DuplicatePayment: + writeInt(&buf, Int32(50)) - case LiquidityFeeTooHigh(message: String) + case .UnsupportedCurrency: + writeInt(&buf, Int32(51)) - case InvalidBlindedPaths(message: String) + case .InsufficientFunds: + writeInt(&buf, Int32(52)) - case AsyncPaymentServicesDisabled(message: String) + case .LiquiditySourceUnavailable: + writeInt(&buf, Int32(53)) - case CannotRbfFundingTransaction(message: String) + case .LiquidityFeeTooHigh: + writeInt(&buf, Int32(54)) - case TransactionNotFound(message: String) + case .InvalidBlindedPaths: + writeInt(&buf, Int32(55)) - case TransactionAlreadyConfirmed(message: String) + case .AsyncPaymentServicesDisabled: + writeInt(&buf, Int32(56)) - case NoSpendableOutputs(message: String) + case .CannotRbfFundingTransaction: + writeInt(&buf, Int32(57)) - case CoinSelectionFailed(message: String) + case .TransactionNotFound: + writeInt(&buf, Int32(58)) - case InvalidMnemonic(message: String) + case .TransactionAlreadyConfirmed: + writeInt(&buf, Int32(59)) - case BackgroundSyncNotEnabled(message: String) + case .NoSpendableOutputs: + writeInt(&buf, Int32(60)) - case AddressTypeAlreadyMonitored(message: String) + case .CoinSelectionFailed: + writeInt(&buf, Int32(61)) - case AddressTypeIsPrimary(message: String) + case .InvalidMnemonic: + writeInt(&buf, Int32(62)) - case AddressTypeNotMonitored(message: String) + case .BackgroundSyncNotEnabled: + writeInt(&buf, Int32(63)) - case OnchainWalletAccountNotRegistered(message: String) + case .AddressTypeAlreadyMonitored: + writeInt(&buf, Int32(64)) - case InvalidSeedBytes(message: String) -} + case .AddressTypeIsPrimary: + writeInt(&buf, Int32(65)) -#if swift(>=5.8) - @_documentation(visibility: private) -#endif -public struct FfiConverterTypeNodeError: FfiConverterRustBuffer { - typealias SwiftType = NodeError + case .AddressTypeNotMonitored: + writeInt(&buf, Int32(66)) - 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 .OnchainWalletAccountNotRegistered: + writeInt(&buf, Int32(67)) - case 2: return try .NotRunning( - message: FfiConverterString.read(from: &buf) - ) + case .InvalidSeedBytes: + writeInt(&buf, Int32(68)) - case 3: return try .OnchainTxCreationFailed( - message: FfiConverterString.read(from: &buf) - ) + case let .OnchainTxBroadcastRejected(txid): + writeInt(&buf, Int32(69)) + FfiConverterTypeTxid.write(txid, into: &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 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) - ) + case let .OnchainTxBroadcastFailed(txid): + writeInt(&buf, Int32(70)) + FfiConverterTypeTxid.write(txid, into: &buf) - default: throw UniffiInternalError.unexpectedEnumCase - } - } + case let .OnchainTxBroadcastTimeout(txid): + writeInt(&buf, Int32(71)) + FfiConverterTypeTxid.write(txid, into: &buf) - public static func write(_ value: NodeError, into buf: inout [UInt8]) { - switch value { - case .AlreadyRunning(_ /* message is ignored*/ ): - writeInt(&buf, Int32(1)) - case .NotRunning(_ /* message is ignored*/ ): - writeInt(&buf, Int32(2)) - case .OnchainTxCreationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(3)) - case .ConnectionFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(4)) - case .InvoiceCreationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(5)) - case .InvoiceRequestCreationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(6)) - case .OfferCreationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(7)) - case .RefundCreationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(8)) - case .PaymentSendingFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(9)) - case .InvalidCustomTlvs(_ /* message is ignored*/ ): - writeInt(&buf, Int32(10)) - case .ProbeSendingFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(11)) - case .RouteNotFound(_ /* message is ignored*/ ): - writeInt(&buf, Int32(12)) - case .ChannelCreationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(13)) - case .ChannelClosingFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(14)) - case .ChannelSplicingFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(15)) - case .ChannelConfigUpdateFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(16)) - case .PersistenceFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(17)) - case .FeerateEstimationUpdateFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(18)) - case .FeerateEstimationUpdateTimeout(_ /* message is ignored*/ ): - writeInt(&buf, Int32(19)) - case .WalletOperationFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(20)) - case .WalletOperationTimeout(_ /* message is ignored*/ ): - writeInt(&buf, Int32(21)) - case .OnchainTxSigningFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(22)) - case .TxSyncFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(23)) - case .TxSyncTimeout(_ /* message is ignored*/ ): - writeInt(&buf, Int32(24)) - case .GossipUpdateFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(25)) - case .GossipUpdateTimeout(_ /* message is ignored*/ ): - writeInt(&buf, Int32(26)) - case .LiquidityRequestFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(27)) - case .UriParameterParsingFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(28)) - case .InvalidAddress(_ /* message is ignored*/ ): - writeInt(&buf, Int32(29)) - case .InvalidSocketAddress(_ /* message is ignored*/ ): - writeInt(&buf, Int32(30)) - case .InvalidPublicKey(_ /* message is ignored*/ ): - writeInt(&buf, Int32(31)) - case .InvalidSecretKey(_ /* message is ignored*/ ): - writeInt(&buf, Int32(32)) - case .InvalidOfferId(_ /* message is ignored*/ ): - writeInt(&buf, Int32(33)) - case .InvalidNodeId(_ /* message is ignored*/ ): - writeInt(&buf, Int32(34)) - case .InvalidPaymentId(_ /* message is ignored*/ ): - writeInt(&buf, Int32(35)) - case .InvalidPaymentHash(_ /* message is ignored*/ ): - writeInt(&buf, Int32(36)) - case .InvalidPaymentPreimage(_ /* message is ignored*/ ): - writeInt(&buf, Int32(37)) - case .InvalidPaymentSecret(_ /* message is ignored*/ ): - writeInt(&buf, Int32(38)) - case .InvalidAmount(_ /* message is ignored*/ ): - writeInt(&buf, Int32(39)) - case .InvalidInvoice(_ /* message is ignored*/ ): - writeInt(&buf, Int32(40)) - case .InvalidOffer(_ /* message is ignored*/ ): - writeInt(&buf, Int32(41)) - case .InvalidRefund(_ /* message is ignored*/ ): - writeInt(&buf, Int32(42)) - case .InvalidChannelId(_ /* message is ignored*/ ): - writeInt(&buf, Int32(43)) - case .InvalidNetwork(_ /* message is ignored*/ ): - writeInt(&buf, Int32(44)) - case .InvalidUri(_ /* message is ignored*/ ): - writeInt(&buf, Int32(45)) - case .InvalidQuantity(_ /* message is ignored*/ ): - writeInt(&buf, Int32(46)) - case .InvalidNodeAlias(_ /* message is ignored*/ ): - writeInt(&buf, Int32(47)) - case .InvalidDateTime(_ /* message is ignored*/ ): - writeInt(&buf, Int32(48)) - case .InvalidFeeRate(_ /* message is ignored*/ ): - writeInt(&buf, Int32(49)) - case .DuplicatePayment(_ /* message is ignored*/ ): - writeInt(&buf, Int32(50)) - case .UnsupportedCurrency(_ /* message is ignored*/ ): - writeInt(&buf, Int32(51)) - case .InsufficientFunds(_ /* message is ignored*/ ): - writeInt(&buf, Int32(52)) - case .LiquiditySourceUnavailable(_ /* message is ignored*/ ): - writeInt(&buf, Int32(53)) - case .LiquidityFeeTooHigh(_ /* message is ignored*/ ): - writeInt(&buf, Int32(54)) - case .InvalidBlindedPaths(_ /* message is ignored*/ ): - writeInt(&buf, Int32(55)) - case .AsyncPaymentServicesDisabled(_ /* message is ignored*/ ): - writeInt(&buf, Int32(56)) - case .CannotRbfFundingTransaction(_ /* message is ignored*/ ): - writeInt(&buf, Int32(57)) - case .TransactionNotFound(_ /* message is ignored*/ ): - writeInt(&buf, Int32(58)) - case .TransactionAlreadyConfirmed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(59)) - case .NoSpendableOutputs(_ /* message is ignored*/ ): - writeInt(&buf, Int32(60)) - case .CoinSelectionFailed(_ /* message is ignored*/ ): - writeInt(&buf, Int32(61)) - case .InvalidMnemonic(_ /* message is ignored*/ ): - writeInt(&buf, Int32(62)) - case .BackgroundSyncNotEnabled(_ /* message is ignored*/ ): - writeInt(&buf, Int32(63)) - case .AddressTypeAlreadyMonitored(_ /* message is ignored*/ ): - writeInt(&buf, Int32(64)) - case .AddressTypeIsPrimary(_ /* message is ignored*/ ): - writeInt(&buf, Int32(65)) - case .AddressTypeNotMonitored(_ /* message is ignored*/ ): - writeInt(&buf, Int32(66)) - case .OnchainWalletAccountNotRegistered(_ /* message is ignored*/ ): - writeInt(&buf, Int32(67)) - case .InvalidSeedBytes(_ /* message is ignored*/ ): - writeInt(&buf, Int32(68)) + case let .OnchainTxBroadcastNotDispatched(txid): + writeInt(&buf, Int32(72)) + FfiConverterTypeTxid.write(txid, into: &buf) } } } @@ -10629,6 +10771,30 @@ private struct FfiConverterOptionTypeBackgroundSyncConfig: FfiConverterRustBuffe } } +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +private struct FfiConverterOptionTypeBroadcastOutcome: FfiConverterRustBuffer { + typealias SwiftType = BroadcastOutcome? + + static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeBroadcastOutcome.write(value, into: &buf) + } + + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeBroadcastOutcome.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -11791,6 +11957,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,9 +13754,15 @@ 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 } + if uniffi_ldk_node_checksum_method_onchainpayment_acknowledge_broadcast_outcome() != 30310 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index() != 63246 { return InitializationResult.apiChecksumMismatch } @@ -13578,6 +13775,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_onchainpayment_address_infos_for_type() != 3701 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_broadcast_outcome() != 15076 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_bump_fee_by_rbf() != 53877 { return InitializationResult.apiChecksumMismatch } @@ -13590,6 +13790,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 +13814,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 f854102379..fb7b05764c 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}; @@ -178,6 +179,20 @@ where &mut self.wallets } + /// Returns every loaded wallet that owns an input or output of `tx`. + pub fn wallet_keys_for_transaction(&self, tx: &Transaction) -> Vec { + self.wallets + .iter() + .filter_map(|(key, wallet)| { + let owns_input = + tx.input.iter().any(|input| wallet.get_utxo(input.previous_output).is_some()); + let owns_output = + tx.output.iter().any(|output| wallet.is_mine(output.script_pubkey.clone())); + (owns_input || owns_output).then_some(*key) + }) + .collect() + } + /// Immutable access to the persister map. pub fn persisters(&self) -> &HashMap { &self.persisters @@ -625,6 +640,74 @@ 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 first_evicted_at = self.next_transaction_update_timestamp(&txids)?; + for (key, wallet) in self.wallets.iter_mut() { + // Evict newest-to-oldest canonical conflicts until no lineage member remains. A single + // graph update can reveal a superseded predecessor, which must then be evicted too. + for pass in 0..=txs.len() { + let remaining = txs + .iter() + .filter(|tx| wallet.get_tx(tx.compute_txid()).is_some()) + .collect::>(); + if remaining.is_empty() { + break; + } + if pass == txs.len() { + return Err(Error::WalletOperationFailed); + } + let evicted_at = first_evicted_at + .checked_add(u64::try_from(pass).map_err(|_| Error::WalletOperationFailed)?) + .ok_or(Error::WalletOperationFailed)?; + wallet + .apply_evicted_txs(remaining.iter().map(|tx| (tx.compute_txid(), evicted_at))); + for tx in remaining { + 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 @@ -1861,6 +1944,181 @@ mod tests { ScriptBuf::new_witness_program(&program) } + #[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 abandoning_replacement_lineage_releases_predecessor_inputs() { + 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 original = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let mut replacement = original.clone(); + replacement.lock_time = LockTime::from_consensus(1); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let spent_outpoint = original.input[0].previous_output; + + aggregate + .apply_mempool_txs(vec![(original.clone(), 1), (replacement.clone(), 2)], Vec::new()) + .unwrap(); + assert_eq!(aggregate.find_tx(replacement_txid), Some(replacement.clone())); + + aggregate.abandon_txs(&[original, replacement]).unwrap(); + + assert_eq!(aggregate.find_tx(original_txid), None); + assert_eq!(aggregate.find_tx(replacement_txid), None); + assert!(aggregate.list_unspent().iter().any(|output| output.outpoint == spent_outpoint)); + } + + #[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..a05881cbe6 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, ExplicitBroadcastGuard, 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, explicit_guard: Option, + ) -> 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::NotDispatched); }; + let mut package_result = Ok(()); for tx in package { - electrum_client.broadcast(tx).await; + let result = electrum_client.broadcast(tx, explicit_guard.clone()).await; + if package_result.is_ok() { + package_result = result; + } } + package_result } pub(super) async fn get_address_balance(&self, address: &bitcoin::Address) -> Option { @@ -831,43 +841,58 @@ impl ElectrumRuntimeClient { }) } - async fn broadcast(&self, tx: Transaction) { + async fn broadcast( + &self, tx: Transaction, explicit_guard: Option, + ) -> Result<(), TxBroadcastError> { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); let tx_bytes = tx.encode(); - let spawn_fut = - self.runtime_handle.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); - let timeout_fut = - tokio::time::timeout(Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), spawn_fut); + let spawn_fut = self.runtime_handle.spawn_blocking(move || { + let _explicit_guard = explicit_guard; + electrum_client.transaction_broadcast(&tx) + }); - 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 +982,25 @@ 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(error) if error.to_string().to_ascii_lowercase().contains("timed out") => { + Err(TxBroadcastError::Timeout) + }, + Err(error) if error.to_string().to_ascii_lowercase().contains("timeout") => { + Err(TxBroadcastError::Timeout) + }, + Err(_) => Err(TxBroadcastError::Failed), + } +} + struct ConfirmGate { active: AtomicBool, } @@ -1053,6 +1097,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 +1108,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 +1196,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, None)), 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..e8e1ebe400 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,21 @@ 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) + }, + _ if error.to_string().to_ascii_lowercase().contains("timed out") => { + Err(TxBroadcastError::Timeout) + }, + _ if error.to_string().to_ascii_lowercase().contains("timeout") => { + Err(TxBroadcastError::Timeout) + }, + _ => 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 +608,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..bcb67890a8 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -424,7 +424,7 @@ mod sync_tests { // Process BDK wallet events and emit corresponding ldk-node events via the event queue. // When a transaction touches multiple wallet accounts, each wallet emits its own // BdkWalletEvent, so we deduplicate by txid before forwarding to the event queue. -async fn process_wallet_events( +pub(crate) async fn process_wallet_events( wallet_events: Vec, wallet: &crate::wallet::Wallet, event_queue: &EventQueue, logger: &Arc, channel_manager: Option<&Arc>, _chain_monitor: Option<&Arc>, @@ -478,10 +478,11 @@ where confirmation_time: block_time.confirmation_time, details, }; - event_queue.add_event(event).await.map_err(|e| { + event_queue.add_event_if_absent(event).await.map_err(|e| { log_error!(logger, "Failed to push onchain event to queue: {}", e); e })?; + wallet.mark_locally_applied_unconfirmed_delivered(txid)?; }, BdkWalletEvent::TxUnconfirmed { txid, old_block_time, .. } => { match old_block_time { @@ -524,10 +525,14 @@ where ); 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 - })?; + event_queue.add_event_with_idempotency_key(event, txid).await.map_err( + |e| { + log_error!(logger, "Failed to push onchain event to queue: {}", e); + e + }, + )?; + wallet.mark_locally_applied_unconfirmed_delivered(txid)?; + event_queue.clear_idempotency_key(txid).await?; }, } }, @@ -565,6 +570,33 @@ where }, } } + + for txid in wallet.ready_locally_applied_unconfirmed_txids()? { + if !seen_received_txids.insert(txid) { + wallet.mark_locally_applied_unconfirmed_delivered(txid)?; + continue; + } + if seen_confirmed_txids.contains(&txid) || transaction_confirmations.contains_key(&txid) { + wallet.mark_locally_applied_unconfirmed_delivered(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_with_idempotency_key(event, txid).await.map_err(|e| { + log_error!(logger, "Failed to push onchain event to queue: {}", e); + e + })?; + wallet.mark_locally_applied_unconfirmed_delivered(txid)?; + event_queue.clear_idempotency_key(txid).await?; + } Ok(()) } @@ -1295,33 +1327,66 @@ 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; + let mut explicit_jobs = futures_util::stream::FuturesUnordered::new(); loop { let tx_bcast_logger = Arc::clone(&self.logger); + let crate::tx_broadcaster::BroadcastQueueReceivers { ldk_receiver, explicit_receiver } = + &mut *receivers; tokio::select! { _ = stop_tx_bcast_receiver.changed() => { + self.tx_broadcaster.pause_explicit_broadcasts(); + while let Ok(request) = explicit_receiver.try_recv() { + if request.try_claim() { + request.send_result(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)); + } + } log_debug!( tx_bcast_logger, "Stopping broadcasting transactions.", ); return; } - Some(next_package) = receiver.recv() => { - match &self.kind { - ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await - }, - ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await - }, - ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await - }, + Some(request) = ldk_receiver.recv() => { + self.process_broadcast_request(request).await; + } + Some(request) = explicit_receiver.recv(), + if explicit_jobs.len() < crate::tx_broadcaster::MAX_IN_FLIGHT_EXPLICIT_BROADCASTS => { + if request.try_claim() { + explicit_jobs.push(self.process_broadcast_request(request)); } } + Some(()) = futures_util::StreamExt::next(&mut explicit_jobs), if !explicit_jobs.is_empty() => { + } } } } + + async fn process_broadcast_request(&self, request: crate::tx_broadcaster::BroadcastRequest) { + let explicit_guard = request.take_explicit_guard(); + let crate::tx_broadcaster::BroadcastRequest { + package, + result_sender, + ldk_claim: _ldk_claim, + explicit_claim: _explicit_claim, + } = request; + let result = match &self.kind { + ChainSourceKind::Esplora(source) => { + let _explicit_guard = explicit_guard; + source.process_broadcast_package(package).await + }, + ChainSourceKind::Electrum(source) => { + source.process_broadcast_package(package, explicit_guard).await + }, + ChainSourceKind::Bitcoind(source) => { + let _explicit_guard = explicit_guard; + source.process_broadcast_package(package).await + }, + }; + if let Some(result_sender) = result_sender { + let _ = result_sender.send(result); + } + } } impl Filter for ChainSource { diff --git a/src/config.rs b/src/config.rs index c271b71200..2a157cf166 100644 --- a/src/config.rs +++ b/src/config.rs @@ -128,6 +128,25 @@ pub enum AddressType { } impl AddressType { + pub(crate) fn serialization_tag(self) -> u8 { + match self { + AddressType::Legacy => 0, + AddressType::NestedSegwit => 1, + AddressType::NativeSegwit => 2, + AddressType::Taproot => 3, + } + } + + pub(crate) fn from_serialization_tag(tag: u8) -> Option { + match tag { + 0 => Some(AddressType::Legacy), + 1 => Some(AddressType::NestedSegwit), + 2 => Some(AddressType::NativeSegwit), + 3 => Some(AddressType::Taproot), + _ => None, + } + } + /// Returns `true` for address types with a native witness `scriptPubKey` /// (`NativeSegwit` and `Taproot`). Required by BOLT 2 for channel scripts. pub fn is_native_witness(&self) -> bool { 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/event.rs b/src/event.rs index 0ba8cefab1..527419d20f 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,13 +7,13 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint, TxIn, TxOut}; +use bitcoin::{Amount, OutPoint, TxIn, TxOut, Txid}; use lightning::events::bump_transaction::BumpTransactionEvent; use lightning::events::{ ClosureReason, Event as LdkEvent, PaymentFailureReason, PaymentPurpose, ReplayEvent, @@ -983,6 +983,8 @@ where L::Target: LdkLogger, { queue: Arc>>, + idempotency_keys: Arc>>, + mutation_lock: tokio::sync::Mutex<()>, waker: Arc>>, kv_store: Arc, logger: L, @@ -994,15 +996,19 @@ where { pub(crate) fn new(kv_store: Arc, logger: L) -> Self { let queue = Arc::new(Mutex::new(VecDeque::new())); + let idempotency_keys = Arc::new(Mutex::new(HashSet::new())); + let mutation_lock = tokio::sync::Mutex::new(()); let waker = Arc::new(Mutex::new(None)); - Self { queue, waker, kv_store, logger } + Self { queue, idempotency_keys, mutation_lock, waker, kv_store, logger } } pub(crate) async fn add_event(&self, event: Event) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; let data = { let mut locked_queue = self.queue.lock().unwrap(); locked_queue.push_back(event); - EventQueueSerWrapper(&locked_queue).encode() + let locked_keys = self.idempotency_keys.lock().unwrap(); + EventQueueSerWrapper(&locked_queue, &locked_keys).encode() }; self.persist_queue(data).await?; @@ -1013,6 +1019,57 @@ where Ok(()) } + pub(crate) async fn add_event_if_absent(&self, event: Event) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let data = { + let mut locked_queue = self.queue.lock().unwrap(); + if !locked_queue.contains(&event) { + locked_queue.push_back(event); + } + let locked_keys = self.idempotency_keys.lock().unwrap(); + EventQueueSerWrapper(&locked_queue, &locked_keys).encode() + }; + + self.persist_queue(data).await?; + if let Some(waker) = self.waker.lock().unwrap().take() { + waker.wake(); + } + Ok(()) + } + + pub(crate) async fn add_event_with_idempotency_key( + &self, event: Event, idempotency_key: Txid, + ) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let data = { + let mut locked_queue = self.queue.lock().unwrap(); + let mut locked_keys = self.idempotency_keys.lock().unwrap(); + if locked_keys.insert(idempotency_key) && !locked_queue.contains(&event) { + locked_queue.push_back(event); + } + EventQueueSerWrapper(&locked_queue, &locked_keys).encode() + }; + + self.persist_queue(data).await?; + if let Some(waker) = self.waker.lock().unwrap().take() { + waker.wake(); + } + Ok(()) + } + + pub(crate) async fn clear_idempotency_key(&self, idempotency_key: Txid) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let data = { + let locked_queue = self.queue.lock().unwrap(); + let mut locked_keys = self.idempotency_keys.lock().unwrap(); + if !locked_keys.remove(&idempotency_key) { + return Ok(()); + } + EventQueueSerWrapper(&locked_queue, &locked_keys).encode() + }; + self.persist_queue(data).await + } + pub(crate) fn next_event(&self) -> Option { let locked_queue = self.queue.lock().unwrap(); locked_queue.front().cloned() @@ -1023,10 +1080,12 @@ where } pub(crate) async fn event_handled(&self) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; let data = { let mut locked_queue = self.queue.lock().unwrap(); locked_queue.pop_front(); - EventQueueSerWrapper(&locked_queue).encode() + let locked_keys = self.idempotency_keys.lock().unwrap(); + EventQueueSerWrapper(&locked_queue, &locked_keys).encode() }; self.persist_queue(data).await?; @@ -1072,12 +1131,14 @@ where let (kv_store, logger) = args; let read_queue: EventQueueDeserWrapper = Readable::read(reader)?; let queue = Arc::new(Mutex::new(read_queue.0)); + let idempotency_keys = Arc::new(Mutex::new(read_queue.1)); + let mutation_lock = tokio::sync::Mutex::new(()); let waker = Arc::new(Mutex::new(None)); - Ok(Self { queue, waker, kv_store, logger }) + Ok(Self { queue, idempotency_keys, mutation_lock, waker, kv_store, logger }) } } -struct EventQueueDeserWrapper(VecDeque); +struct EventQueueDeserWrapper(VecDeque, HashSet); impl Readable for EventQueueDeserWrapper { fn read( @@ -1088,11 +1149,26 @@ impl Readable for EventQueueDeserWrapper { for _ in 0..len { queue.push_back(Readable::read(reader)?); } - Ok(Self(queue)) + let extension_version = match u8::read(reader) { + Ok(version) => version, + Err(lightning::ln::msgs::DecodeError::ShortRead) => { + return Ok(Self(queue, HashSet::new())) + }, + Err(error) => return Err(error), + }; + if extension_version != 1 { + return Err(lightning::ln::msgs::DecodeError::InvalidValue); + } + let key_count: u16 = Readable::read(reader)?; + let mut idempotency_keys = HashSet::with_capacity(key_count as usize); + for _ in 0..key_count { + idempotency_keys.insert(Readable::read(reader)?); + } + Ok(Self(queue, idempotency_keys)) } } -struct EventQueueSerWrapper<'a>(&'a VecDeque); +struct EventQueueSerWrapper<'a>(&'a VecDeque, &'a HashSet); impl Writeable for EventQueueSerWrapper<'_> { fn write(&self, writer: &mut W) -> Result<(), lightning::io::Error> { @@ -1100,6 +1176,19 @@ impl Writeable for EventQueueSerWrapper<'_> { for e in self.0.iter() { e.write(writer)?; } + 1u8.write(writer)?; + let mut idempotency_keys = self.1.iter().copied().collect::>(); + idempotency_keys.sort_unstable(); + let key_count = u16::try_from(idempotency_keys.len()).map_err(|_| { + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "too many event idempotency keys", + ) + })?; + key_count.write(writer)?; + for key in idempotency_keys { + key.write(writer)?; + } Ok(()) } } @@ -2502,14 +2591,121 @@ where #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicU16, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; use std::time::Duration; + use bitcoin::hashes::Hash; use lightning::util::test_utils::TestLogger; use super::*; use crate::io::test_utils::InMemoryStore; + struct DelayedWriteStore { + inner: Arc, + block_next_write: AtomicBool, + write_started: Arc, + release_write: Arc, + } + + impl DelayedWriteStore { + fn new() -> Self { + Self { + inner: Arc::new(InMemoryStore::new()), + block_next_write: AtomicBool::new(false), + write_started: Arc::new(tokio::sync::Notify::new()), + release_write: Arc::new(tokio::sync::Notify::new()), + } + } + } + + impl KVStore for DelayedWriteStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> std::pin::Pin, lightning::io::Error>> + Send>> + { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> std::pin::Pin> + Send>> { + let inner = Arc::clone(&self.inner); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + let should_block = self.block_next_write.swap(false, Ordering::SeqCst); + let write_started = Arc::clone(&self.write_started); + let release_write = Arc::clone(&self.release_write); + Box::pin(async move { + if should_block { + write_started.notify_one(); + release_write.notified().await; + } + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + }) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> std::pin::Pin> + Send>> { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> std::pin::Pin, lightning::io::Error>> + Send>> + { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl lightning::util::persist::KVStoreSync for DelayedWriteStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Result, lightning::io::Error> { + lightning::util::persist::KVStoreSync::read( + &*self.inner, + primary_namespace, + secondary_namespace, + key, + ) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Result<(), lightning::io::Error> { + lightning::util::persist::KVStoreSync::write( + &*self.inner, + primary_namespace, + secondary_namespace, + key, + buf, + ) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Result<(), lightning::io::Error> { + lightning::util::persist::KVStoreSync::remove( + &*self.inner, + primary_namespace, + secondary_namespace, + key, + lazy, + ) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Result, lightning::io::Error> { + lightning::util::persist::KVStoreSync::list( + &*self.inner, + primary_namespace, + secondary_namespace, + ) + } + } + #[tokio::test] async fn event_queue_persistence() { let store: Arc = Arc::new(InMemoryStore::new()); @@ -2548,6 +2744,90 @@ mod tests { assert_eq!(event_queue.next_event(), None); } + #[tokio::test] + async fn legacy_event_queue_without_idempotency_extension_is_readable() { + let store: Arc = Arc::new(InMemoryStore::new()); + let logger = Arc::new(TestLogger::new()); + let expected_event = Event::ChannelReady { + channel_id: ChannelId([24u8; 32]), + user_channel_id: UserChannelId(2424), + counterparty_node_id: None, + funding_txo: None, + }; + let mut legacy_bytes = Vec::new(); + 1u16.write(&mut legacy_bytes).unwrap(); + expected_event.write(&mut legacy_bytes).unwrap(); + + let event_queue = EventQueue::read(&mut &legacy_bytes[..], (store, logger)).unwrap(); + + assert_eq!(event_queue.next_event_async().await, expected_event); + } + + #[tokio::test] + async fn idempotent_event_insert_does_not_duplicate_an_existing_queue_entry() { + let store: Arc = Arc::new(InMemoryStore::new()); + let logger = Arc::new(TestLogger::new()); + let event_queue = EventQueue::new(store, logger); + let event = Event::ChannelReady { + channel_id: ChannelId([42u8; 32]), + user_channel_id: UserChannelId(42), + counterparty_node_id: None, + funding_txo: None, + }; + + event_queue.add_event_if_absent(event.clone()).await.unwrap(); + event_queue.add_event_if_absent(event.clone()).await.unwrap(); + + assert_eq!(event_queue.next_event(), Some(event)); + event_queue.event_handled().await.unwrap(); + assert_eq!(event_queue.next_event(), None); + } + + #[tokio::test] + async fn handled_event_is_not_restored_by_concurrent_idempotency_cleanup() { + let store = Arc::new(DelayedWriteStore::new()); + let dyn_store: Arc = store.clone(); + let logger = Arc::new(TestLogger::new()); + let event_queue = Arc::new(EventQueue::new(Arc::clone(&dyn_store), Arc::clone(&logger))); + let txid = Txid::from_byte_array([42; 32]); + let event = Event::OnchainTransactionReceived { + txid, + details: TransactionDetails { + amount_sats: 42, + inputs: Vec::new(), + outputs: Vec::new(), + }, + }; + event_queue.add_event_with_idempotency_key(event, txid).await.unwrap(); + + store.block_next_write.store(true, Ordering::SeqCst); + let cleanup_queue = Arc::clone(&event_queue); + let cleanup = tokio::spawn(async move { cleanup_queue.clear_idempotency_key(txid).await }); + store.write_started.notified().await; + + let handled_queue = Arc::clone(&event_queue); + let mut handled = tokio::spawn(async move { handled_queue.event_handled().await }); + assert!( + tokio::time::timeout(Duration::from_millis(200), &mut handled).await.is_err(), + "event acknowledgement must wait for the cleanup write" + ); + store.release_write.notify_one(); + cleanup.await.unwrap().unwrap(); + handled.await.unwrap().unwrap(); + + let persisted_bytes = KVStore::read( + &*dyn_store, + EVENT_QUEUE_PERSISTENCE_PRIMARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_SECONDARY_NAMESPACE, + EVENT_QUEUE_PERSISTENCE_KEY, + ) + .await + .unwrap(); + let restored_queue = + EventQueue::read(&mut &persisted_bytes[..], (dyn_store, logger)).unwrap(); + assert_eq!(restored_queue.next_event(), None); + } + #[tokio::test] async fn event_queue_concurrency() { let store: Arc = Arc::new(InMemoryStore::new()); diff --git a/src/ffi/types.rs b/src/ffi/types.rs index c2d7074f0a..6e8005541c 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -59,7 +59,9 @@ pub use crate::payment::store::{ }; pub use crate::payment::QrPaymentResult; #[allow(unused_imports)] -pub use crate::payment::{AddressInfo, KeychainKind}; +pub use crate::payment::{ + AddressInfo, BroadcastOutcome, BroadcastOutcomeStatus, 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..ce952c4169 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -28,6 +28,18 @@ 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 = ""; + +/// Durable terminal outcomes for acceptance-unknown on-chain broadcasts. +pub(crate) const ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE: &str = "onchain_broadcast_outcomes"; +pub(crate) const ONCHAIN_BROADCAST_OUTCOME_SECONDARY_NAMESPACE: &str = ""; + +/// Durable delivery state for locally prepared on-chain receive events. +pub(crate) const ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE: &str = "onchain_broadcast_events"; +pub(crate) const ONCHAIN_BROADCAST_EVENT_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..861d5af2c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,7 +155,9 @@ 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, BroadcastOutcome, BroadcastOutcomeStatus, KeychainKind, PendingBroadcastInfo, +}; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, UnifiedQrPayment, @@ -356,6 +358,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 +830,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 +855,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 +1117,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 +1131,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 +2480,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 +2499,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 +2547,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 +2577,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 +2605,163 @@ 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.send_result(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)); + 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.send_result(Ok(())); + 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..b0d8b56f4e 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -17,7 +17,10 @@ mod unified_qr; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; -pub use onchain::{AddressInfo, KeychainKind, OnchainPayment}; +pub use onchain::{ + AddressInfo, BroadcastOutcome, BroadcastOutcomeStatus, 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..d5adb921f0 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -7,15 +7,17 @@ //! Holds a payment handler allowing to send and receive on-chain payments. -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; 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, ExplicitBroadcastGuard, TxBroadcastError}; +use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; #[cfg(not(feature = "uniffi"))] @@ -67,6 +69,85 @@ 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, +} + +/// The durable reconciliation status of an explicit on-chain broadcast. +/// +/// Only [`Self::Accepted`] proves that the configured backend accepted a transaction. Callers +/// must keep [`Self::Pending`] fail-closed, while [`Self::Abandoned`] proves that the wallet +/// completed the caller-authorized local abandonment workflow. +/// +/// # Safety and threat model +/// +/// No Rust memory-safety preconditions apply. Treat every status except [`Self::Accepted`] as +/// insufficient authorization to publish transaction proof to an external service. +/// +/// # Example +/// +/// ``` +/// use ldk_node::payment::BroadcastOutcomeStatus; +/// +/// assert_ne!(BroadcastOutcomeStatus::Pending, BroadcastOutcomeStatus::Accepted); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BroadcastOutcomeStatus { + /// Backend acceptance is unresolved and no proof may be submitted. + Pending, + /// The configured backend accepted the authoritative transaction. + Accepted, + /// The unresolved spend was conclusively reconciled and abandoned locally. + Abandoned, +} + +/// A durable outcome for an explicit on-chain broadcast and its complete RBF lineage. +/// +/// `txid` is the current retry target for [`BroadcastOutcomeStatus::Pending`], the authoritative +/// backend-observed member for [`BroadcastOutcomeStatus::Accepted`], and the last active member for +/// [`BroadcastOutcomeStatus::Abandoned`]. Looking up any member of `lineage` returns the same +/// record. +/// +/// # Safety and threat model +/// +/// No Rust memory-safety preconditions apply. Consumers must use `status`, not transaction +/// presence in ordinary payment history, as the broadcast-acceptance authority. +/// +/// # Example +/// +/// ``` +/// use bitcoin::hashes::Hash; +/// use bitcoin::Txid; +/// use ldk_node::payment::{BroadcastOutcome, BroadcastOutcomeStatus}; +/// +/// let txid = Txid::from_byte_array([1; 32]); +/// let outcome = BroadcastOutcome { +/// status: BroadcastOutcomeStatus::Pending, +/// txid, +/// lineage: vec![txid], +/// }; +/// assert_eq!(outcome.txid, txid); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BroadcastOutcome { + /// Durable reconciliation status. + pub status: BroadcastOutcomeStatus, + /// Current active or terminal transaction ID for this outcome. + pub txid: Txid, + /// Complete replacement 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,18 +179,133 @@ 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>, logger: Arc, } +struct BroadcastDispatchLease { + wallet: Arc, + txids: Mutex>, +} + +impl BroadcastDispatchLease { + fn acquire(wallet: &Arc, txid: Txid) -> Result { + wallet.begin_broadcast_dispatch(txid)?; + Ok(Self { wallet: Arc::clone(wallet), txids: Mutex::new(vec![txid]) }) + } + + fn include(&self, txid: Txid) -> Result<(), Error> { + let mut txids = self.txids.lock().unwrap(); + if !txids.contains(&txid) { + self.wallet.begin_broadcast_dispatch(txid)?; + txids.push(txid); + } + Ok(()) + } +} + +impl Drop for BroadcastDispatchLease { + fn drop(&mut self) { + self.wallet.end_broadcast_dispatches(&self.txids.lock().unwrap()); + } +} + 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(); + let dispatch_lease = Arc::new(BroadcastDispatchLease::acquire(&self.wallet, txid)?); + self.wallet.prepare_pending_broadcast(&tx)?; + let explicit_guard: ExplicitBroadcastGuard = dispatch_lease.clone(); + let dispatch_result = match self.runtime.try_block_on( + self.tx_broadcaster.broadcast_transaction(admission, tx.clone(), Some(explicit_guard)), + ) { + Ok(result) => result, + Err(Error::NotRunning) => { + drop(dispatch_lease); + self.wallet.abandon_broadcast_intent(&tx)?; + return Err(Error::NotRunning); + }, + Err(error) => return Err(error), + }; + match dispatch_result { + Ok(()) => self.record_accepted_broadcast(txid), + Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { + drop(dispatch_lease); + 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.record_unknown_broadcast(txid); + Err(Self::initial_broadcast_error(error, txid)) + }, + } + } + + fn record_accepted_broadcast(&self, txid: Txid) -> Result { + match self.wallet.clear_broadcast_intent(&txid) { + Ok(()) => Ok(txid), + Err(error) => { + log_error!(self.logger, "Failed to persist accepted broadcast {}: {}", txid, error); + if let Err(retention_error) = self.wallet.mark_broadcast_outcome_required(&txid) { + log_error!( + self.logger, + "Failed to retain accepted broadcast outcome {}: {}", + txid, + retention_error + ); + } + Err(Error::OnchainTxBroadcastFailed { txid }) + }, + } + } + + fn record_unknown_broadcast(&self, txid: Txid) { + if let Err(e) = self.wallet.mark_broadcast_outcome_required(&txid) { + log_error!( + self.logger, + "Failed to persist acceptance-unknown broadcast outcome {}: {}", + txid, + e + ); + } + } + + 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 +626,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 +733,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 +750,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 +758,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 +784,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 +801,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 +812,175 @@ 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 dispatch_lease = Arc::new(BroadcastDispatchLease::acquire(&self.wallet, *txid)?); + + let tx = self.wallet.recover_pending_broadcast(txid)?.ok_or(Error::TransactionNotFound)?; + let explicit_guard: ExplicitBroadcastGuard = dispatch_lease.clone(); + let dispatch_result = match self.runtime.try_block_on( + self.tx_broadcaster.broadcast_transaction(admission, tx, Some(explicit_guard)), + ) { + Ok(result) => result, + Err(error) => return Err(error), + }; + match dispatch_result { + Ok(()) => self.record_accepted_broadcast(*txid), + Err(error) => { + self.record_unknown_broadcast(*txid); + 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()) + } + + /// Returns the durable broadcast outcome associated with any transaction in an RBF lineage. + /// + /// [`BroadcastOutcomeStatus::Pending`] is not proof of backend acceptance. Only + /// [`BroadcastOutcomeStatus::Accepted`] identifies an authoritative accepted transaction. + /// Terminal outcomes survive restart, confirmation, payment-history cleanup, and abandonment + /// without a time-based expiry until [`Self::acknowledge_broadcast_outcome`] removes them. + /// `None` means the transaction is unknown or its terminal outcome was already acknowledged. + /// Persistence failures are returned and must remain unresolved by callers. + /// + /// # Safety and trust boundary + /// + /// No Rust memory-safety preconditions apply. The configured backend is the authority for + /// acceptance, while explicit abandonment remains caller-authorized only after independent + /// mempool and chain reconciliation. + /// + /// # Example + /// + /// ``` + /// # use bitcoin::Txid; + /// # use ldk_node::payment::{BroadcastOutcome, OnchainPayment}; + /// # use ldk_node::NodeError; + /// # fn outcome( + /// # payment: &OnchainPayment, + /// # txid: &Txid, + /// # ) -> Result, NodeError> { + /// payment.broadcast_outcome(txid) + /// # } + /// ``` + pub fn broadcast_outcome(&self, txid: &Txid) -> Result, Error> { + Ok(self.wallet.broadcast_outcome(txid)?.map(|(status, txid, lineage)| BroadcastOutcome { + status, + txid, + lineage, + })) + } + + /// Acknowledges and removes a durable terminal broadcast outcome. + /// + /// The lookup key may be any member of the recorded RBF lineage. Acknowledgement is idempotent: + /// an unknown or already acknowledged transaction succeeds without changing state. A tracked + /// lineage that is still active returns [`Error::OnchainTxBroadcastFailed`]. Call this only after + /// the consumer has durably handled a [`BroadcastOutcomeStatus::Accepted`] or + /// [`BroadcastOutcomeStatus::Abandoned`] result. Storage failures are returned and leave the + /// outcome queryable for retry. + /// + /// # Safety and trust boundary + /// + /// No Rust memory-safety preconditions apply. The caller owns durable downstream delivery and + /// must not acknowledge before the terminal result can be recovered independently. + /// + /// # Example + /// + /// ``` + /// # use bitcoin::Txid; + /// # use ldk_node::payment::OnchainPayment; + /// # use ldk_node::NodeError; + /// # fn acknowledge( + /// # payment: &OnchainPayment, + /// # txid: &Txid, + /// # ) -> Result<(), NodeError> { + /// payment.acknowledge_broadcast_outcome(txid) + /// # } + /// ``` + pub fn acknowledge_broadcast_outcome(&self, txid: &Txid) -> Result<(), Error> { + self.wallet.acknowledge_broadcast_outcome(txid) + } + + /// 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, [`Error::TransactionNotFound`] if + /// `txid` is not the active transaction returned by [`Self::list_pending_broadcasts`], and + /// [`Error::OnchainTxBroadcastFailed`] while the transaction is being dispatched. + /// + /// # 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 +998,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 +1008,16 @@ 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()?; + let dispatch_lease = Arc::new(BroadcastDispatchLease::acquire(&self.wallet, *txid)?); // Pass through to the wallet implementation #[cfg(not(feature = "uniffi"))] @@ -635,7 +1025,34 @@ 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(); + dispatch_lease.include(replacement_txid)?; + let explicit_guard: ExplicitBroadcastGuard = dispatch_lease.clone(); + let dispatch_result = match self.runtime.try_block_on( + self.tx_broadcaster.broadcast_transaction(admission, replacement, Some(explicit_guard)), + ) { + Ok(result) => result, + Err(Error::NotRunning) => { + self.wallet.reject_rbf_broadcast(&replacement_txid)?; + return Err(Error::NotRunning); + }, + Err(error) => return Err(error), + }; + match dispatch_result { + Ok(()) => self.record_accepted_broadcast(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.record_unknown_broadcast(replacement_txid); + Err(Self::initial_broadcast_error(error, replacement_txid)) + }, + } } /// Accelerates confirmation of a transaction using Child-Pays-For-Parent (CPFP). @@ -725,3 +1142,676 @@ 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::{Amount, Network, ScriptBuf, Transaction, TxOut, 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, ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE}; + use crate::payment::BroadcastOutcomeStatus; + use crate::tx_broadcaster::TxBroadcastError; + use crate::types::DynStore; + use crate::{Event, Node}; + + #[derive(Default)] + struct BroadcastWriteState { + armed: bool, + blocked: bool, + released: bool, + } + + struct BlockingBroadcastIntentStore { + inner: InMemoryStore, + state: Mutex, + state_changed: Condvar, + fail_next_write_namespace: Mutex>, + } + + impl BlockingBroadcastIntentStore { + fn new() -> Self { + Self { + inner: InMemoryStore::new(), + state: Mutex::new(BroadcastWriteState::default()), + state_changed: Condvar::new(), + fail_next_write_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 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; + } + + fn fail_armed_write(&self, primary_namespace: &str) -> io::Result<()> { + let mut namespace = self.fail_next_write_namespace.lock().unwrap(); + if namespace.as_deref() == Some(primary_namespace) { + namespace.take(); + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace write failure")) + } else { + Ok(()) + } + } + } + + 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); + self.fail_armed_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![], + } + } + + fn tracked_test_transaction(script_pubkey: ScriptBuf) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(47), + input: vec![], + output: vec![TxOut { value: Amount::from_sat(1), script_pubkey }], + } + } + + #[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 } + ); + } + + #[test] + fn accepted_result_persistence_failure_returns_a_transaction_keyed_unknown_outcome() { + let concrete_store = Arc::new(BlockingBroadcastIntentStore::new()); + let store: Arc = concrete_store.clone(); + let node = test_node(store); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + node.wallet.begin_broadcast_dispatch(txid).unwrap(); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + concrete_store.fail_next_write_in(ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE); + + assert_eq!( + node.onchain_payment().record_accepted_broadcast(txid), + Err(Error::OnchainTxBroadcastFailed { txid }) + ); + node.wallet.end_broadcast_dispatches(&[txid]); + assert_eq!( + node.onchain_payment().broadcast_outcome(&txid).unwrap().map(|outcome| outcome.status), + Some(BroadcastOutcomeStatus::Pending) + ); + } + + #[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 node = test_node(Arc::clone(&store)); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + 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.send_result(Err(TxBroadcastError::Failed)); + drop(receivers); + assert_eq!(initial_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + crate::chain::process_wallet_events( + Vec::new(), + &node.wallet, + &node.event_queue, + &node.logger, + Some(&node.channel_manager), + None, + ) + .await + .unwrap(); + assert!(matches!( + node.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + node.event_handled().unwrap(); + 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.send_result(Err(TxBroadcastError::NotDispatched)); + 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.send_result(Ok(())); + drop(receivers); + assert_eq!(accepted_call.await.unwrap(), Ok(txid)); + assert!(restarted_node.wallet.list_pending_broadcasts().unwrap().is_empty()); + crate::chain::process_wallet_events( + Vec::new(), + &restarted_node.wallet, + &restarted_node.event_queue, + &restarted_node.logger, + Some(&restarted_node.channel_manager), + None, + ) + .await + .unwrap(); + assert!(restarted_node.next_event().is_none()); + *restarted_node.is_running.write().unwrap() = false; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn accepted_send_publishes_received_event_after_restart() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = test_node(Arc::clone(&store)); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let send = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.send_result(Ok(())); + drop(receivers); + assert_eq!(send.await.unwrap(), Ok(txid)); + drop(node); + + let restarted = test_node(store); + crate::chain::process_wallet_events( + Vec::new(), + &restarted.wallet, + &restarted.event_queue, + &restarted.logger, + Some(&restarted.channel_manager), + None, + ) + .await + .unwrap(); + assert!(matches!( + restarted.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn handled_received_event_is_not_repeated_after_delivery_write_failure() { + let concrete_store = Arc::new(BlockingBroadcastIntentStore::new()); + let store: Arc = concrete_store.clone(); + let node = test_node(Arc::clone(&store)); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + node.wallet.publish_locally_applied_unconfirmed(txid).unwrap(); + concrete_store.fail_next_write_in(crate::io::ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE); + + assert_eq!( + crate::chain::process_wallet_events( + Vec::new(), + &node.wallet, + &node.event_queue, + &node.logger, + Some(&node.channel_manager), + None, + ) + .await, + Err(Error::PersistenceFailed) + ); + assert!(matches!( + node.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + node.event_handled().unwrap(); + drop(node); + + let restarted = test_node(store); + crate::chain::process_wallet_events( + Vec::new(), + &restarted.wallet, + &restarted.event_queue, + &restarted.logger, + Some(&restarted.channel_manager), + None, + ) + .await + .unwrap(); + assert!(restarted.next_event().is_none()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn backend_observation_after_restart_publishes_failed_send_event() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = test_node(Arc::clone(&store)); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + node.wallet.mark_broadcast_outcome_required(&txid).unwrap(); + drop(node); + + let restarted = test_node(store); + restarted.wallet.apply_mempool_txs(vec![(tx, 1)], Vec::new()).unwrap(); + crate::chain::process_wallet_events( + Vec::new(), + &restarted.wallet, + &restarted.event_queue, + &restarted.logger, + Some(&restarted.channel_manager), + None, + ) + .await + .unwrap(); + assert!(matches!( + restarted.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn claimed_dispatch_cannot_be_abandoned() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = test_node(store); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!( + node.wallet.abandon_broadcast_intent_by_txid(&txid), + Err(Error::OnchainTxBroadcastFailed { txid }) + ); + request.send_result(Err(TxBroadcastError::Failed)); + drop(receivers); + assert_eq!(dispatch.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn stopped_worker_retains_dispatch_lease_until_blocking_backend_finishes() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = test_node(store); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + let explicit_guard = request.take_explicit_guard(); + let crate::tx_broadcaster::BroadcastRequest { + result_sender, + package: _, + explicit_claim: _, + ldk_claim: _, + } = request; + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let backend = tokio::task::spawn_blocking(move || { + let _explicit_guard = explicit_guard.unwrap(); + release_receiver.recv().unwrap(); + }); + node.tx_broadcaster.pause_explicit_broadcasts(); + drop(result_sender); + drop(receivers); + + assert_eq!(dispatch.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!( + node.wallet.abandon_broadcast_intent_by_txid(&txid), + Err(Error::OnchainTxBroadcastFailed { txid }) + ); + + release_sender.send(()).unwrap(); + backend.await.unwrap(); + node.wallet.abandon_broadcast_intent_by_txid(&txid).unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn accepted_send_returns_unknown_when_event_outbox_write_fails() { + let concrete_store = Arc::new(BlockingBroadcastIntentStore::new()); + let store: Arc = concrete_store.clone(); + let node = test_node(Arc::clone(&store)); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let send_tx = tx.clone(); + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, send_tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + concrete_store.fail_next_write_in(crate::io::ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE); + let explicit_guard = request.take_explicit_guard(); + let crate::tx_broadcaster::BroadcastRequest { + result_sender, + package: _, + explicit_claim: _, + ldk_claim: _, + } = request; + result_sender.unwrap().send(Ok(())).unwrap(); + drop(explicit_guard); + drop(receivers); + + assert_eq!(dispatch.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(node.wallet.ready_locally_applied_unconfirmed_txids().unwrap(), vec![txid]); + drop(node); + + let restarted = test_node(store); + restarted.wallet.apply_mempool_txs(vec![(tx, 1)], Vec::new()).unwrap(); + crate::chain::process_wallet_events( + Vec::new(), + &restarted.wallet, + &restarted.event_queue, + &restarted.logger, + Some(&restarted.channel_manager), + None, + ) + .await + .unwrap(); + assert!(matches!( + restarted.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unknown_send_preserves_txid_error_when_event_outbox_write_fails() { + for backend_error in [TxBroadcastError::Failed, TxBroadcastError::Timeout] { + let concrete_store = Arc::new(BlockingBroadcastIntentStore::new()); + let store: Arc = concrete_store.clone(); + let node = test_node(store); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + concrete_store.fail_next_write_in(crate::io::ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE); + let explicit_guard = request.take_explicit_guard(); + let crate::tx_broadcaster::BroadcastRequest { + result_sender, + package: _, + explicit_claim: _, + ldk_claim: _, + } = request; + result_sender.unwrap().send(Err(backend_error)).unwrap(); + drop(explicit_guard); + drop(receivers); + + let expected_error = match backend_error { + TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed { txid }, + TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout { txid }, + _ => unreachable!(), + }; + assert_eq!(dispatch.await.unwrap(), Err(expected_error)); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sync_delivery_during_dispatch_is_not_republished_by_late_success() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = test_node(store); + let tx = + tracked_test_transaction(node.onchain_payment().new_address().unwrap().script_pubkey()); + let txid = tx.compute_txid(); + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + let send_tx = tx.clone(); + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, send_tx) + }); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + + node.wallet.apply_mempool_txs(vec![(tx, 1)], Vec::new()).unwrap(); + crate::chain::process_wallet_events( + Vec::new(), + &node.wallet, + &node.event_queue, + &node.logger, + Some(&node.channel_manager), + None, + ) + .await + .unwrap(); + assert!(matches!( + node.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + node.event_handled().unwrap(); + + let explicit_guard = request.take_explicit_guard(); + let crate::tx_broadcaster::BroadcastRequest { + result_sender, + package: _, + explicit_claim: _, + ldk_claim: _, + } = request; + result_sender.unwrap().send(Ok(())).unwrap(); + drop(explicit_guard); + drop(receivers); + assert_eq!(dispatch.await.unwrap(), Ok(txid)); + + crate::chain::process_wallet_events( + Vec::new(), + &node.wallet, + &node.event_queue, + &node.logger, + Some(&node.channel_manager), + None, + ) + .await + .unwrap(); + assert!(node.next_event().is_none()); + } + + #[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.send_result(Err(TxBroadcastError::Rejected)); + 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..fd9b7a47da 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,37 +6,375 @@ // 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, Semaphore}; +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; +pub(crate) const MAX_IN_FLIGHT_EXPLICIT_BROADCASTS: usize = 50; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TxBroadcastError { + Rejected, + NotDispatched, + Failed, + Timeout, +} + +pub(crate) type ExplicitBroadcastGuard = Arc; + +struct ExplicitBroadcastResources { + _guard: Option, + _permit: tokio::sync::OwnedSemaphorePermit, +} + +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)) + || 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", + "too-long-mempool-chain", + "absurdly-high-fee", + "tx-size", + ] + .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; + +pub(crate) struct ExplicitBroadcastClaim { + state: AtomicU8, + explicit_guard: std::sync::Mutex>, +} + +impl ExplicitBroadcastClaim { + fn new(explicit_guard: Option) -> Self { + Self { + state: AtomicU8::new(EXPLICIT_BROADCAST_QUEUED), + explicit_guard: std::sync::Mutex::new(explicit_guard), + } + } + + 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 { + let cancelled = self + .state + .compare_exchange( + EXPLICIT_BROADCAST_QUEUED, + EXPLICIT_BROADCAST_CANCELLED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok(); + if cancelled { + drop(self.take_explicit_guard()); + } + cancelled + } + + fn take_explicit_guard(&self) -> Option { + self.explicit_guard.lock().unwrap().take() + } +} + +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>>, + pub(crate) explicit_claim: Option>, + pub(crate) ldk_claim: Option, +} + +pub(crate) struct LdkBroadcastClaim { + key: Vec, + queued_packages: Arc>>>, +} + +impl Drop for LdkBroadcastClaim { + fn drop(&mut self) { + self.queued_packages.lock().unwrap().remove(&self.key); + } +} + +impl BroadcastRequest { + fn explicit( + package: Vec, result_sender: oneshot::Sender>, + explicit_guard: Option, + ) -> (Self, Arc) { + let explicit_claim = Arc::new(ExplicitBroadcastClaim::new(explicit_guard)); + ( + Self { + package, + result_sender: Some(result_sender), + explicit_claim: Some(Arc::clone(&explicit_claim)), + ldk_claim: None, + }, + explicit_claim, + ) + } + + fn ldk( + package: Vec, key: Vec, + queued_packages: Arc>>>, + ) -> Self { + Self { + package, + result_sender: None, + explicit_claim: None, + ldk_claim: Some(LdkBroadcastClaim { key, queued_packages }), + } + } + + pub(crate) fn try_claim(&self) -> bool { + self.explicit_claim.as_ref().map_or(true, |claim| claim.try_claim()) + } + + pub(crate) fn send_result(self, result: Result<(), TxBroadcastError>) { + let Self { result_sender, explicit_claim, .. } = self; + drop(explicit_claim.and_then(|claim| claim.take_explicit_guard())); + if let Some(result_sender) = result_sender { + let _ = result_sender.send(result); + } + } + + pub(crate) fn take_explicit_guard(&self) -> Option { + self.explicit_claim.as_ref().and_then(|claim| claim.take_explicit_guard()) + } +} + +/// Separate receivers for safety-critical LDK traffic and bounded explicit user sends. +pub(crate) struct BroadcastQueueReceivers { + pub(crate) ldk_receiver: mpsc::UnboundedReceiver, + pub(crate) explicit_receiver: mpsc::Receiver, +} + +impl BroadcastQueueReceivers { + #[cfg(test)] + 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; + } + request.send_result(Err(TxBroadcastError::NotDispatched)); + } + } +} pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + ldk_sender: mpsc::UnboundedSender, + ldk_queued_packages: Arc>>>, + explicit_sender: mpsc::Sender, + queue_receivers: Mutex, + explicit_broadcast_run: std::sync::Mutex>>, + explicit_broadcast_slots: Arc, 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 ldk_queued_packages = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())); + 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, + ldk_queued_packages, + explicit_sender, + queue_receivers, + explicit_broadcast_run: std::sync::Mutex::new(Some(Arc::new(ExplicitBroadcastRun))), + explicit_broadcast_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_EXPLICIT_BROADCASTS)), + 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) } - pub(crate) async fn get_broadcast_queue( + /// 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_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, + explicit_guard: Option, + ) -> Result<(), TxBroadcastError> { + self.broadcast_transaction_with_timeout( + admission, + tx, + Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), + explicit_guard, + ) + .await + } + + async fn broadcast_transaction_with_timeout( + &self, admission: ExplicitBroadcastAdmission, tx: Transaction, timeout: Duration, + explicit_guard: Option, + ) -> Result<(), TxBroadcastError> { + let permit = Arc::clone(&self.explicit_broadcast_slots) + .try_acquire_owned() + .map_err(|_| TxBroadcastError::NotDispatched)?; + let explicit_guard: ExplicitBroadcastGuard = + Arc::new(ExplicitBroadcastResources { _guard: explicit_guard, _permit: permit }); + let (result_sender, result_receiver) = oneshot::channel(); + let (request, explicit_claim) = + BroadcastRequest::explicit(vec![tx], result_sender, Some(explicit_guard)); + { + 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 +384,401 @@ 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 key = package.iter().map(Transaction::compute_txid).collect::>(); + if !self.ldk_queued_packages.lock().unwrap().insert(key.clone()) { + return; + } + let request = BroadcastRequest::ldk(package, key, Arc::clone(&self.ldk_queued_packages)); + 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, ExplicitBroadcastGuard, + TransactionBroadcaster, TxBroadcastError, EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE, + MAX_IN_FLIGHT_EXPLICIT_BROADCASTS, + }; + + 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( + None, + r#"sendrawtransaction: {\"code\":-26,\"message\":\"too-long-mempool-chain\"}"#, + ), + 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(), None); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.send_result(Ok(())); + }; + + 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(), None); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.send_result(Err(TxBroadcastError::Rejected)); + }; + + 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(), None); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.send_result(Err(TxBroadcastError::Failed)); + }; + + 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), + None, + ); + 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.send_result(Ok(())); + }; + + 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), + None, + ) + .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), + None, + ); + 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.send_result(Ok(())); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn queued_initial_broadcast_timeout_releases_its_guard() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let guard = Arc::new(()); + let weak_guard = Arc::downgrade(&guard); + let explicit_guard: ExplicitBroadcastGuard = guard; + + let result = broadcaster + .broadcast_transaction_with_timeout( + broadcaster.begin_explicit_broadcast().unwrap(), + test_transaction(), + Duration::from_millis(10), + Some(explicit_guard), + ) + .await; + + assert_eq!(result, Err(TxBroadcastError::NotDispatched)); + assert!(weak_guard.upgrade().is_none()); + assert_eq!( + broadcaster.explicit_broadcast_slots.available_permits(), + MAX_IN_FLIGHT_EXPLICIT_BROADCASTS + ); + } + + #[tokio::test] + async fn stalled_explicit_broadcasts_are_globally_bounded() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let mut calls = Vec::new(); + for lock_time in 0..MAX_IN_FLIGHT_EXPLICIT_BROADCASTS { + let broadcaster = Arc::clone(&broadcaster); + calls.push(tokio::spawn(async move { + broadcaster + .broadcast_transaction_with_timeout( + broadcaster.begin_explicit_broadcast().unwrap(), + test_transaction_with_lock_time(lock_time as u32), + Duration::from_secs(10), + None, + ) + .await + })); + } + let mut stalled_requests = Vec::new(); + for _ in 0..MAX_IN_FLIGHT_EXPLICIT_BROADCASTS { + stalled_requests.push(receivers.recv().await.unwrap()); + } + + assert_eq!(broadcaster.explicit_broadcast_slots.available_permits(), 0); + assert_eq!( + broadcaster + .broadcast_transaction_with_timeout( + broadcaster.begin_explicit_broadcast().unwrap(), + test_transaction(), + Duration::from_secs(1), + None, + ) + .await, + Err(TxBroadcastError::NotDispatched) + ); + + for request in stalled_requests { + request.send_result(Ok(())); + } + for call in calls { + assert_eq!(call.await.unwrap(), 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), + None, + ) + .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), + None, + ); + 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.send_result(Ok(())); + }; + + 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), + None, + ); + 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), + None, + ) + .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), + None, + ); + 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.send_result(Ok(())); + }; + + 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 duplicate_ldk_packages_are_coalesced_until_dispatch_finishes() { + let broadcaster = TransactionBroadcaster::new(Arc::new(TestLogger::new())); + let tx = test_transaction(); + broadcaster.broadcast_transactions(&[&tx]); + 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.clone()]); + assert!(tokio::time::timeout(Duration::from_millis(20), receivers.recv()).await.is_err()); + + drop(request); + broadcaster.broadcast_transactions(&[&tx]); + assert_eq!(receivers.recv().await.unwrap().package, vec![tx]); + } + + #[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, None); + 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), + None, + ) + .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 241ee1cde5..9fae8aae66 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,9 +60,16 @@ use crate::config::{ }; use crate::event::{TxInput, TxOutput}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; +use crate::io::{ + ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_EVENT_SECONDARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_OUTCOME_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}; +use crate::payment::{ + BroadcastOutcomeStatus, KeychainKind, PaymentDetails, PaymentDirection, PaymentStatus, +}; use crate::types::{Broadcaster, ChannelManager, DynStore, PaymentStore}; use crate::{Error, NodeMetrics}; @@ -67,6 +77,357 @@ 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 LEGACY_LINEAGE_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 4; +const LEGACY_ACCOUNT_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 5; +const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 6; +const ONCHAIN_BROADCAST_OUTCOME_SERIALIZATION_VERSION: u8 = 1; +const NO_PREDECESSOR_INDEX: u64 = u64::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BroadcastEventState { + Held = 0, + Ready = 1, + Delivered = 2, +} + +impl BroadcastEventState { + fn decode(bytes: &[u8]) -> Result { + match bytes { + [0] => Ok(Self::Held), + [1] => Ok(Self::Ready), + [2] => Ok(Self::Delivered), + _ => Err(Error::PersistenceFailed), + } + } +} + +#[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, + participant_accounts: Vec, + // Set before an acceptance-unknown result crosses the public API boundary. Terminal outcomes + // for such lineages remain durable until explicit consumer acknowledgement. + requires_outcome: bool, +} + +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], + participant_accounts: Vec::new(), + requires_outcome: false, + } + } + + fn replacement( + existing: Option, original: Transaction, replacement: Transaction, + ) -> Result { + let original_txid = original.compute_txid(); + 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_from(original_txid, 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, + participant_accounts: Vec::new(), + requires_outcome: true, + }) + } + + 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_from( + &mut self, predecessor_txid: Txid, replacement: Transaction, + ) -> Result<(), Error> { + if !matches!(self.state, BroadcastIntentState::Pending | BroadcastIntentState::Accepted) { + return Err(Error::WalletOperationFailed); + } + let predecessor_index = self + .transactions + .iter() + .position(|transaction| transaction.compute_txid() == predecessor_txid) + .ok_or(Error::TransactionNotFound)?; + let replaces_active = replacement.input.iter().any(|replacement_input| { + self.transactions[predecessor_index].input.iter().any(|active_input| { + active_input.previous_output == replacement_input.previous_output + }) + }); + if !replaces_active { + return Err(Error::OnchainTxCreationFailed); + } + let predecessor_was_pending = u8::from( + self.state == BroadcastIntentState::Pending + && predecessor_index == self.active_index as usize, + ); + self.transactions.push(replacement); + self.predecessor_indexes + .push(u64::try_from(predecessor_index).map_err(|_| Error::WalletOperationFailed)?); + 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(()) + } + + #[cfg(test)] + fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { + self.supersede_from(self.active_txid(), replacement) + } + + 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 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StoredBroadcastOutcomeStatus { + Accepted, + Abandoned, +} + +impl StoredBroadcastOutcomeStatus { + fn serialization_tag(self) -> u8 { + match self { + Self::Accepted => 0, + Self::Abandoned => 1, + } + } + + fn from_serialization_tag(tag: u8) -> Result { + match tag { + 0 => Ok(Self::Accepted), + 1 => Ok(Self::Abandoned), + _ => Err(Error::PersistenceFailed), + } + } + + fn public_status(self) -> BroadcastOutcomeStatus { + match self { + Self::Accepted => BroadcastOutcomeStatus::Accepted, + Self::Abandoned => BroadcastOutcomeStatus::Abandoned, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct StoredBroadcastOutcome { + status: StoredBroadcastOutcomeStatus, + txid: Txid, + lineage: Vec, + retain_until_ack: bool, +} + +impl StoredBroadcastOutcome { + fn key(&self) -> Option { + self.lineage.first().copied() + } + + fn is_valid(&self) -> bool { + !self.lineage.is_empty() + && self.lineage.contains(&self.txid) + && self.lineage.iter().copied().collect::>().len() == self.lineage.len() + } +} fn validate_derivation_index(index: u32) -> Result<(), Error> { if index > BIP32_MAX_NORMAL_INDEX { @@ -88,6 +449,59 @@ 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 observed_txid = if intent.state == BroadcastIntentState::Pending { + observed_txids.contains(&intent.active_txid()).then_some(intent.active_txid()) + } else { + intent + .transactions + .iter() + .rev() + .map(Transaction::compute_txid) + .find(|txid| observed_txids.contains(txid)) + }; + observed_txid.map(|txid| (*key, 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 { @@ -155,6 +569,12 @@ 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>, + // Prevents a same-process abandon from racing a broadcast already claimed for dispatch. + inflight_broadcast_txids: Mutex>, // Serializes account membership, primary selection, and account reloads. operation_lock: Mutex<()>, account_generation: AtomicU64, @@ -195,6 +615,9 @@ impl Wallet { let operation_lock = Mutex::new(()); Self { inner, + broadcast_intent_lock: Mutex::new(()), + broadcast_intents: Mutex::new(HashMap::new()), + inflight_broadcast_txids: Mutex::new(HashSet::new()), operation_lock, account_generation: AtomicU64::new(0), synced_derived_accounts: Mutex::new(HashSet::new()), @@ -212,6 +635,34 @@ impl Wallet { } } + pub(crate) fn begin_broadcast_dispatch(&self, txid: Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + if !self.inflight_broadcast_txids.lock().unwrap().insert(txid) { + return Err(Error::OnchainTxBroadcastFailed { txid }); + } + Ok(()) + } + + pub(crate) fn end_broadcast_dispatches(&self, txids: &[Txid]) { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + { + let mut inflight = self.inflight_broadcast_txids.lock().unwrap(); + for txid in txids { + inflight.remove(txid); + } + } + for txid in txids { + if let Err(e) = self.cleanup_delivered_broadcast_event_if_resolved(*txid) { + log_error!( + self.logger, + "Failed to clean delivered broadcast event {}: {}", + txid, + e + ); + } + } + } + pub(crate) fn is_funding_transaction( &self, txid: &Txid, channel_manager: &ChannelManager, ) -> bool { @@ -367,6 +818,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| { @@ -383,10 +840,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); @@ -402,6 +862,7 @@ impl Wallet { e })?; } + drop(locked); Ok(()) } @@ -692,6 +1153,9 @@ impl Wallet { if !runtime_config.derived_accounts.contains(&wallet_account) { runtime_config.derived_accounts.push(wallet_account); } + drop(runtime_config); + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; log_info!(self.logger, "Derived account {:?} already loaded", wallet_account); return Ok(()); } @@ -729,6 +1193,8 @@ impl Wallet { } log_info!(self.logger, "Registered derived on-chain wallet account {:?}", wallet_account); + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; Ok(()) } @@ -742,6 +1208,16 @@ impl Wallet { let wallet_account = OnchainWalletAccount { address_type, account_index }; let _op = self.operation_lock.lock().unwrap(); + let _intent = self.broadcast_intent_lock.lock().unwrap(); + if self + .broadcast_intents + .lock() + .unwrap() + .values() + .any(|intent| intent.participant_accounts.contains(&wallet_account)) + { + return Err(Error::WalletOperationFailed); + } if !self .address_type_runtime_config .read() @@ -788,9 +1264,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 { @@ -808,12 +1307,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, @@ -833,81 +1355,1244 @@ 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) } - // 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( - &self, txid: &Txid, fee_rate: FeeRate, channel_manager: &ChannelManager, - ) -> Result { - // Check if this is a funding transaction - if self.is_funding_transaction(txid, channel_manager) { + /// 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(); + let mut locked_wallet = self.inner.lock().unwrap(); + let mut intent = BroadcastIntent::new(tx.clone()); + intent.participant_accounts = locked_wallet.wallet_keys_for_transaction(tx); + self.write_broadcast_intent(&intent)?; + self.note_locally_applied_unconfirmed(txid)?; + 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 }); + } + 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)?; + if !self.broadcast_intent_accounts_loaded(&intent) { + return Err(Error::OnchainWalletAccountNotRegistered); + } + if intent.transactions.iter().any(|transaction| { + self.inflight_broadcast_txids.lock().unwrap().contains(&transaction.compute_txid()) + }) { + return Err(Error::OnchainTxBroadcastFailed { txid: *txid }); + } + 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, - "Cannot RBF transaction {}: it is a channel funding transaction", - txid + "Failed to abandon pending transaction {}: {}", + intent.active_txid(), + e ); - return Err(Error::CannotRbfFundingTransaction); + 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.persist_terminal_broadcast_outcome(&intent, StoredBroadcastOutcomeStatus::Abandoned)?; + 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(); - - let (tx, original_fee) = locked_wallet.build_rbf(*txid, fee_rate).map_err(|e| match e { - bdk_wallet_aggregate::Error::TransactionNotFound => { - log_error!(self.logger, "Transaction not found in any wallet: {}", txid); - Error::TransactionNotFound - }, - bdk_wallet_aggregate::Error::TransactionAlreadyConfirmed => { - log_error!(self.logger, "Cannot replace confirmed transaction: {}", txid); - Error::TransactionAlreadyConfirmed - }, - bdk_wallet_aggregate::Error::InvalidFeeRate => { - log_error!(self.logger, "RBF rejected: new fee rate is not higher"); - Error::InvalidFeeRate - }, - bdk_wallet_aggregate::Error::InsufficientFunds => { - log_error!(self.logger, "Insufficient funds for RBF fee bump of {}", txid); - Error::InsufficientFunds - }, - bdk_wallet_aggregate::Error::UtxoNotFoundLocally(outpoint) => { - log_error!( - self.logger, - "Cannot calculate fee for RBF of {}: input UTXO {} not found locally. \ - Try syncing the wallet first.", - txid, - outpoint, - ); - Error::OnchainTxCreationFailed - }, - other => { - log_error!(self.logger, "Failed to build RBF for {}: {}", txid, other); - Error::OnchainTxCreationFailed + 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)) + } - // Persist wallet changes - locked_wallet.persist_all().map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; + /// 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()) + } - // Extract and broadcast the transaction - self.broadcaster.broadcast_transactions(&[&tx]); + 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()) + } - let new_txid = tx.compute_txid(); + /// Resolves an intent or retained terminal record by any lineage member. + pub(crate) fn broadcast_outcome( + &self, txid: &Txid, + ) -> Result)>, Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + if let Some((_, intent)) = self.find_broadcast_intent_by_lineage_txid(txid) { + let status = match intent.state { + BroadcastIntentState::Accepted + | BroadcastIntentState::Observing + | BroadcastIntentState::Confirming => BroadcastOutcomeStatus::Accepted, + BroadcastIntentState::Pending + | BroadcastIntentState::Rejecting + | BroadcastIntentState::Abandoning => BroadcastOutcomeStatus::Pending, + }; + let lineage = + intent.transactions.iter().map(Transaction::compute_txid).collect::>(); + return Ok(Some((status, intent.active_txid(), lineage))); + } - // Calculate and log the actual fee increase achieved - let new_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); - let actual_fee_rate = new_fee / tx.weight(); + Ok(self + .find_stored_broadcast_outcome_by_lineage_txid(txid)? + .map(|(_, outcome)| (outcome.status.public_status(), outcome.txid, outcome.lineage))) + } - log_info!(self.logger, "RBF transaction created successfully!"); + /// Removes one terminal record while rejecting acknowledgement of any live lineage. + pub(crate) fn acknowledge_broadcast_outcome(&self, txid: &Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + if let Some((_, intent)) = self.find_broadcast_intent_by_lineage_txid(txid) { + return Err(Error::OnchainTxBroadcastFailed { txid: intent.active_txid() }); + } + if let Some((outcome_key, _)) = self.find_stored_broadcast_outcome_by_lineage_txid(txid)? { + self.remove_broadcast_outcome(&outcome_key)?; + } + Ok(()) + } + + /// Makes terminal retention durable before returning an acceptance-unknown result. + pub(crate) fn mark_broadcast_outcome_required(&self, txid: &Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + if let Some((_, mut intent)) = self.find_broadcast_intent_by_lineage_txid(txid) { + if !intent.requires_outcome { + intent.requires_outcome = true; + self.write_broadcast_intent(&intent)?; + } + } else if let Some((_, mut outcome)) = + self.find_stored_broadcast_outcome_by_lineage_txid(txid)? + { + if !outcome.retain_until_ack { + outcome.retain_until_ack = true; + self.write_broadcast_outcome(&outcome)?; + } + } else { + log_error!( + self.logger, + "Acceptance-unknown broadcast {} has no durable intent or outcome", + txid + ); + return Err(Error::PersistenceFailed); + } + self.publish_locally_applied_unconfirmed(*txid) + } + + pub(crate) fn ready_locally_applied_unconfirmed_txids(&self) -> Result, Error> { + let mut keys = KVStoreSync::list( + &*self.kv_store, + ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_EVENT_SECONDARY_NAMESPACE, + ) + .map_err(|_| Error::PersistenceFailed)?; + keys.sort_unstable(); + keys.into_iter() + .filter_map(|key| { + let txid = match Txid::from_str(&key) { + Ok(txid) => txid, + Err(_) => return Some(Err(Error::PersistenceFailed)), + }; + match self.read_broadcast_event_state(&txid) { + Ok(Some(BroadcastEventState::Ready)) => Some(Ok(txid)), + Ok(_) => None, + Err(error) => Some(Err(error)), + } + }) + .collect() + } + + #[cfg(test)] + fn take_locally_applied_unconfirmed_txids(&self) -> Vec { + let ready = self.ready_locally_applied_unconfirmed_txids().unwrap(); + for txid in &ready { + self.mark_locally_applied_unconfirmed_delivered(*txid).unwrap(); + } + ready + } + + fn read_broadcast_event_state( + &self, txid: &Txid, + ) -> Result, Error> { + match KVStoreSync::read( + &*self.kv_store, + ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_EVENT_SECONDARY_NAMESPACE, + &txid.to_string(), + ) { + Ok(bytes) => BroadcastEventState::decode(&bytes).map(Some), + Err(error) if error.kind() == lightning::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(Error::PersistenceFailed), + } + } + + fn write_broadcast_event_state( + &self, txid: Txid, state: BroadcastEventState, + ) -> Result<(), Error> { + KVStoreSync::write( + &*self.kv_store, + ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_EVENT_SECONDARY_NAMESPACE, + &txid.to_string(), + vec![state as u8], + ) + .map_err(|_| Error::PersistenceFailed) + } + + fn note_locally_applied_unconfirmed(&self, txid: Txid) -> Result<(), Error> { + if self.read_broadcast_event_state(&txid)?.is_none() { + self.write_broadcast_event_state(txid, BroadcastEventState::Held)?; + } + Ok(()) + } + + pub(crate) fn publish_locally_applied_unconfirmed(&self, txid: Txid) -> Result<(), Error> { + if self.read_broadcast_event_state(&txid)? != Some(BroadcastEventState::Delivered) { + self.write_broadcast_event_state(txid, BroadcastEventState::Ready)?; + } + Ok(()) + } + + pub(crate) fn mark_locally_applied_unconfirmed_delivered( + &self, txid: Txid, + ) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + let belongs_to_intent = self + .broadcast_intents + .lock() + .unwrap() + .values() + .any(|intent| intent.transactions.iter().any(|tx| tx.compute_txid() == txid)); + let dispatch_inflight = self.inflight_broadcast_txids.lock().unwrap().contains(&txid); + if belongs_to_intent || dispatch_inflight { + self.write_broadcast_event_state(txid, BroadcastEventState::Delivered) + } else { + self.forget_locally_applied_unconfirmed(&[txid]) + } + } + + fn forget_locally_applied_unconfirmed(&self, txids: &[Txid]) -> Result<(), Error> { + for txid in txids { + KVStoreSync::remove( + &*self.kv_store, + ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_EVENT_SECONDARY_NAMESPACE, + &txid.to_string(), + false, + ) + .map_err(|_| Error::PersistenceFailed)?; + } + Ok(()) + } + + fn cleanup_delivered_broadcast_event_if_resolved(&self, txid: Txid) -> Result<(), Error> { + let still_tracked = self + .broadcast_intents + .lock() + .unwrap() + .values() + .any(|intent| intent.transactions.iter().any(|tx| tx.compute_txid() == txid)); + if !still_tracked + && self.read_broadcast_event_state(&txid)? == Some(BroadcastEventState::Delivered) + { + self.forget_locally_applied_unconfirmed(&[txid])?; + } + Ok(()) + } + + /// 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()?; + if let Some((intent_key, intent)) = self.find_broadcast_intent_by_active_txid(txid) { + self.resolve_broadcast_intent(intent_key, intent, *txid, false)?; + } else if self + .find_stored_broadcast_outcome_by_lineage_txid(txid)? + .filter(|(_, outcome)| outcome.status == StoredBroadcastOutcomeStatus::Accepted) + .is_none() + { + return Err(Error::TransactionNotFound); + } + if let Some((outcome_key, outcome)) = + self.find_stored_broadcast_outcome_by_lineage_txid(txid)? + { + if !outcome.retain_until_ack { + self.remove_broadcast_outcome(&outcome_key)?; + } + } + self.cleanup_delivered_broadcast_event_if_resolved(*txid) + } + + /// 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 unresolved_txids = pending_intents + .iter() + .filter(|(_, intent)| intent.has_pending_transaction()) + .map(|(_, intent)| intent.active_txid()) + .collect::>(); + let unresolved_txs = pending_intents + .into_iter() + .filter(|(_, intent)| intent.has_pending_transaction()) + .map(|(_, intent)| intent) + .collect(); + self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + for txid in unresolved_txids { + self.note_locally_applied_unconfirmed(txid)?; + } + Ok(()) + } + + 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(), + u8::from(intent.requires_outcome), + &intent.predecessor_indexes, + &intent.predecessor_was_pending, + intent + .participant_accounts + .iter() + .map(|account| account.address_type.serialization_tag()) + .collect::>(), + intent + .participant_accounts + .iter() + .map(|account| u64::from(account.account_index)) + .collect::>(), + &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 find_broadcast_intent_by_lineage_txid( + &self, txid: &Txid, + ) -> Option<(Txid, BroadcastIntent)> { + self.broadcast_intents + .lock() + .unwrap() + .iter() + .find(|(_, intent)| { + intent.transactions.iter().any(|transaction| transaction.compute_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 + })?; + let mut intent = BroadcastIntent::new(tx); + intent.requires_outcome = true; + intent + }, + 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(&LEGACY_LINEAGE_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, + participant_accounts: Vec::new(), + requires_outcome: true, + } + }, + Some(&LEGACY_ACCOUNT_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let ( + active_index, + state_tag, + predecessor_indexes, + predecessor_was_pending, + account_type_tags, + account_indexes, + transactions, + ) = deserialize::<(u32, u8, Vec, Vec, Vec, Vec, Vec)>( + &bytes[1..], + ) + .map_err(|_| Error::PersistenceFailed)?; + if account_type_tags.len() != account_indexes.len() { + return Err(Error::PersistenceFailed); + } + let state = BroadcastIntentState::from_serialization_tag(state_tag)?; + let participant_accounts = account_type_tags + .into_iter() + .zip(account_indexes) + .map(|(tag, account_index)| { + let account_index = + u32::try_from(account_index).map_err(|_| Error::PersistenceFailed)?; + AddressType::from_serialization_tag(tag) + .map(|address_type| OnchainWalletAccount { + address_type, + account_index, + }) + .ok_or(Error::PersistenceFailed) + }) + .collect::, _>>()?; + BroadcastIntent { + transactions, + active_index, + state, + predecessor_indexes, + predecessor_was_pending, + participant_accounts, + requires_outcome: true, + } + }, + Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let ( + active_index, + state_tag, + requires_outcome, + predecessor_indexes, + predecessor_was_pending, + account_type_tags, + account_indexes, + transactions, + ) = + deserialize::<( + u32, + u8, + u8, + Vec, + Vec, + Vec, + Vec, + Vec, + )>(&bytes[1..]) + .map_err(|_| Error::PersistenceFailed)?; + if requires_outcome > 1 || account_type_tags.len() != account_indexes.len() { + return Err(Error::PersistenceFailed); + } + let state = BroadcastIntentState::from_serialization_tag(state_tag)?; + let participant_accounts = account_type_tags + .into_iter() + .zip(account_indexes) + .map(|(tag, account_index)| { + let account_index = + u32::try_from(account_index).map_err(|_| Error::PersistenceFailed)?; + AddressType::from_serialization_tag(tag) + .map(|address_type| OnchainWalletAccount { + address_type, + account_index, + }) + .ok_or(Error::PersistenceFailed) + }) + .collect::, _>>()?; + BroadcastIntent { + transactions, + active_index, + state, + predecessor_indexes, + predecessor_was_pending, + participant_accounts, + requires_outcome: requires_outcome != 0, + } + }, + _ => { + 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(()) + } + + /// Atomically replaces the single terminal record keyed by the lineage root. + fn write_broadcast_outcome(&self, outcome: &StoredBroadcastOutcome) -> Result<(), Error> { + if !outcome.is_valid() { + log_error!(self.logger, "Refusing to persist an invalid broadcast outcome"); + return Err(Error::PersistenceFailed); + } + let outcome_key = outcome.key().ok_or(Error::PersistenceFailed)?; + let lineage_bytes = + outcome.lineage.iter().flat_map(|txid| txid.to_byte_array()).collect::>(); + let mut bytes = vec![ONCHAIN_BROADCAST_OUTCOME_SERIALIZATION_VERSION]; + bytes.extend(serialize(&( + outcome.status.serialization_tag(), + u8::from(outcome.retain_until_ack), + outcome.txid, + &lineage_bytes, + ))); + KVStoreSync::write( + &*self.kv_store, + ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_OUTCOME_SECONDARY_NAMESPACE, + &outcome_key.to_string(), + bytes, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to persist broadcast outcome {}: {}", outcome_key, e); + Error::PersistenceFailed + }) + } + + /// Reads and validates one terminal record without accepting partial or aliased keys. + fn read_broadcast_outcome_from_store( + &self, outcome_key: &Txid, + ) -> Result, Error> { + let bytes = match KVStoreSync::read( + &*self.kv_store, + ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_OUTCOME_SECONDARY_NAMESPACE, + &outcome_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 outcome {}: {}", outcome_key, e); + return Err(Error::PersistenceFailed); + }, + }; + if bytes.first() != Some(&ONCHAIN_BROADCAST_OUTCOME_SERIALIZATION_VERSION) { + log_error!(self.logger, "Unsupported broadcast outcome version for {}", outcome_key); + return Err(Error::PersistenceFailed); + } + let (status_tag, retain_until_ack, txid, lineage_bytes) = + deserialize::<(u8, u8, Txid, Vec)>(&bytes[1..]) + .map_err(|_| Error::PersistenceFailed)?; + if retain_until_ack > 1 || lineage_bytes.len() % 32 != 0 { + return Err(Error::PersistenceFailed); + } + let lineage = lineage_bytes + .chunks_exact(32) + .map(Txid::from_slice) + .collect::, _>>() + .map_err(|_| Error::PersistenceFailed)?; + let outcome = StoredBroadcastOutcome { + status: StoredBroadcastOutcomeStatus::from_serialization_tag(status_tag)?, + txid, + lineage, + retain_until_ack: retain_until_ack != 0, + }; + if !outcome.is_valid() || outcome.key() != Some(*outcome_key) { + log_error!(self.logger, "Broadcast outcome {} has invalid lineage", outcome_key); + return Err(Error::PersistenceFailed); + } + Ok(Some(outcome)) + } + + /// Reads every terminal record so aliases remain queryable without secondary index writes. + fn read_all_broadcast_outcomes(&self) -> Result, Error> { + let mut keys = KVStoreSync::list( + &*self.kv_store, + ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_OUTCOME_SECONDARY_NAMESPACE, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to list broadcast outcomes: {}", e); + Error::PersistenceFailed + })?; + keys.sort_unstable(); + keys.into_iter() + .map(|key| { + let outcome_key = Txid::from_str(&key).map_err(|e| { + log_error!(self.logger, "Invalid broadcast outcome key {}: {}", key, e); + Error::PersistenceFailed + })?; + let outcome = self + .read_broadcast_outcome_from_store(&outcome_key)? + .ok_or(Error::PersistenceFailed)?; + Ok((outcome_key, outcome)) + }) + .collect() + } + + /// Resolves one lineage alias and treats overlapping durable records as corruption. + fn find_stored_broadcast_outcome_by_lineage_txid( + &self, txid: &Txid, + ) -> Result, Error> { + let mut matches = self + .read_all_broadcast_outcomes()? + .into_iter() + .filter(|(_, outcome)| outcome.lineage.contains(txid)); + let found = matches.next(); + if matches.next().is_some() { + log_error!(self.logger, "Transaction {} belongs to multiple broadcast outcomes", txid); + return Err(Error::PersistenceFailed); + } + Ok(found) + } + + /// Removes every lineage alias atomically by deleting their single root-keyed record. + fn remove_broadcast_outcome(&self, outcome_key: &Txid) -> Result<(), Error> { + KVStoreSync::remove( + &*self.kv_store, + ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_OUTCOME_SECONDARY_NAMESPACE, + &outcome_key.to_string(), + false, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to remove broadcast outcome {}: {}", outcome_key, e); + Error::PersistenceFailed + }) + } + + /// Persists terminal state for exposed-unknown or concurrently dispatched lineages. + fn persist_terminal_broadcast_outcome( + &self, intent: &BroadcastIntent, status: StoredBroadcastOutcomeStatus, + ) -> Result<(), Error> { + let lineage = intent.transactions.iter().map(Transaction::compute_txid).collect::>(); + let inflight_txids = self.inflight_broadcast_txids.lock().unwrap(); + let has_inflight_member = lineage.iter().any(|txid| inflight_txids.contains(txid)); + drop(inflight_txids); + if !intent.requires_outcome && !has_inflight_member { + return Ok(()); + } + self.write_broadcast_outcome(&StoredBroadcastOutcome { + status, + txid: intent.active_txid(), + lineage, + retain_until_ack: intent.requires_outcome, + }) + } + + 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 { + if !self.broadcast_intent_accounts_loaded(&intent) { + continue; + } + 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 broadcast_intent_accounts_loaded(&self, intent: &BroadcastIntent) -> bool { + let loaded = self.inner.lock().unwrap().loaded_keys().into_iter().collect::>(); + intent.participant_accounts.iter().all(|account| loaded.contains(account)) + } + + 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.persist_terminal_broadcast_outcome(&intent, StoredBroadcastOutcomeStatus::Accepted)?; + 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); + + self.publish_locally_applied_unconfirmed(observed_txid)?; + intent.state = BroadcastIntentState::Accepted; + self.persist_terminal_broadcast_outcome(&intent, StoredBroadcastOutcomeStatus::Accepted)?; + if intent.transactions.len() == 1 { + self.remove_broadcast_intent(&intent_key)?; + } else { + self.write_broadcast_intent(&intent)?; + } + self.cleanup_delivered_broadcast_event_if_resolved(observed_txid) + } + + 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)?; + } + if !self.broadcast_intent_accounts_loaded(&intent) { + return Ok(()); + } + return self.complete_confirmation(intent_key, intent); + } + if !self.broadcast_intent_accounts_loaded(&intent) { + intent.begin_observation(observed_txid)?; + self.write_broadcast_intent(&intent)?; + return Ok(()); + } + + intent.mark_accepted(observed_txid)?; + self.publish_locally_applied_unconfirmed(observed_txid)?; + self.persist_terminal_broadcast_outcome(&intent, StoredBroadcastOutcomeStatus::Accepted)?; + 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()))?; + } + } + self.cleanup_delivered_broadcast_event_if_resolved(observed_txid)?; + 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 { + // Check if this is a funding transaction + if self.is_funding_transaction(txid, channel_manager) { + log_error!( + self.logger, + "Cannot RBF transaction {}: it is a channel funding transaction", + txid + ); + return Err(Error::CannotRbfFundingTransaction); + } + + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; + let existing_intent = self.find_broadcast_intent_by_lineage_txid(txid); + let retained_outcome = if existing_intent.is_none() { + self.find_stored_broadcast_outcome_by_lineage_txid(txid)? + .map(|(_, outcome)| outcome.retain_until_ack) + .unwrap_or(false) + } else { + false + }; + let mut locked_wallet = self.inner.lock().unwrap(); + + let (tx, original_fee) = locked_wallet.build_rbf(*txid, fee_rate).map_err(|e| match e { + bdk_wallet_aggregate::Error::TransactionNotFound => { + log_error!(self.logger, "Transaction not found in any wallet: {}", txid); + Error::TransactionNotFound + }, + bdk_wallet_aggregate::Error::TransactionAlreadyConfirmed => { + log_error!(self.logger, "Cannot replace confirmed transaction: {}", txid); + Error::TransactionAlreadyConfirmed + }, + bdk_wallet_aggregate::Error::InvalidFeeRate => { + log_error!(self.logger, "RBF rejected: new fee rate is not higher"); + Error::InvalidFeeRate + }, + bdk_wallet_aggregate::Error::InsufficientFunds => { + log_error!(self.logger, "Insufficient funds for RBF fee bump of {}", txid); + Error::InsufficientFunds + }, + bdk_wallet_aggregate::Error::UtxoNotFoundLocally(outpoint) => { + log_error!( + self.logger, + "Cannot calculate fee for RBF of {}: input UTXO {} not found locally. \ + Try syncing the wallet first.", + txid, + outpoint, + ); + Error::OnchainTxCreationFailed + }, + other => { + log_error!(self.logger, "Failed to build RBF for {}: {}", txid, other); + Error::OnchainTxCreationFailed + }, + })?; + + 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 mut replacement_intent = replacement_intent; + if retained_outcome { + replacement_intent.requires_outcome = true; + } + for account in locked_wallet.wallet_keys_for_transaction(&tx) { + if !replacement_intent.participant_accounts.contains(&account) { + replacement_intent.participant_accounts.push(account); + } + } + let last_seen = self.write_rbf_replacement_intent(&locked_wallet, &replacement_intent)?; + self.note_locally_applied_unconfirmed(new_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 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 }); + } + // Calculate and log the actual fee increase achieved + let new_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); + let actual_fee_rate = new_fee / tx.weight(); + + log_info!(self.logger, "RBF transaction created successfully!"); log_info!(self.logger, " Original: {} ({} sats fee)", txid, original_fee.to_sat()); log_info!( self.logger, @@ -922,7 +2607,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). @@ -1014,8 +2768,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( @@ -1108,6 +2866,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(()) } @@ -1875,10 +3652,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. @@ -1928,8 +3705,6 @@ impl Wallet { tx }; - self.broadcaster.broadcast_transactions(&[&tx]); - let txid = tx.compute_txid(); match send_amount { @@ -1961,7 +3736,7 @@ impl Wallet { }, } - Ok(txid) + Ok(tx) } pub(crate) fn select_confirmed_utxos( @@ -2159,6 +3934,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(); @@ -2180,6 +3977,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!( @@ -2420,20 +4221,252 @@ impl ChangeDestinationSource for WalletKeysManager { .map_err(|_| ()) }) } -} +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bdk_wallet_aggregate::UtxoPsbtInfo; + + use super::{ + additional_input_weight, checked_payment_target, checked_sum, map_wallet_account_error, + observed_broadcast_intents, validate_derivation_index, validate_derivation_range, + BroadcastIntent, BroadcastIntentState, BIP32_MAX_NORMAL_INDEX, + LEGACY_ACCOUNT_BROADCAST_INTENT_SERIALIZATION_VERSION, + 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, + ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + }; + use crate::payment::{ + BroadcastOutcomeStatus, ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, + PaymentStatus, + }; + use crate::types::DynStore; + use crate::Error; + 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_read_namespace: Mutex>, + fail_next_write_namespace: Mutex>, + fail_next_remove_namespace: Mutex>, + fail_next_list_namespace: Mutex>, + } + + impl NamespaceFailStore { + fn new() -> Self { + Self { + inner: InMemoryStore::new(), + fail_next_read_namespace: Mutex::new(None), + fail_next_write_namespace: Mutex::new(None), + fail_next_remove_namespace: Mutex::new(None), + fail_next_list_namespace: Mutex::new(None), + } + } + + fn fail_next_read_in(&self, primary_namespace: &str) { + *self.fail_next_read_namespace.lock().unwrap() = Some(primary_namespace.to_owned()); + } + + 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 fail_next_list_in(&self, primary_namespace: &str) { + *self.fail_next_list_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 read_result(&self, primary_namespace: &str) -> io::Result<()> { + if Self::take_failure(&self.fail_next_read_namespace, primary_namespace) { + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace read 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(()) + } + } + + fn list_result(&self, primary_namespace: &str) -> io::Result<()> { + if Self::take_failure(&self.fail_next_list_namespace, primary_namespace) { + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace list failure")) + } else { + Ok(()) + } + } + } + + impl KVStore for NamespaceFailStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Pin>> + Send + 'static>> { + let result = self.read_result(primary_namespace); + if result.is_err() { + return Box::pin(async move { result.map(|()| Vec::new()) }); + } + 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>> { + let result = self.list_result(primary_namespace); + if result.is_err() { + return Box::pin(async move { result.map(|()| Vec::new()) }); + } + 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> { + self.read_result(primary_namespace)?; + 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> { + self.list_result(primary_namespace)?; + KVStoreSync::list(&self.inner, primary_namespace, secondary_namespace) + } + } -#[cfg(test)] -mod tests { - use bdk_wallet_aggregate::UtxoPsbtInfo; - use bitcoin::{psbt, OutPoint, TxIn, Weight}; + 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![], + } + } - use super::{ - additional_input_weight, checked_payment_target, checked_sum, map_wallet_account_error, - validate_derivation_index, validate_derivation_range, BIP32_MAX_NORMAL_INDEX, - MAX_ADDRESS_INFO_BATCH_COUNT, - }; - use crate::config::{AddressType, OnchainWalletAccount}; - use crate::Error; + 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() { @@ -2504,6 +4537,1160 @@ mod tests { ); } + #[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 broadcast_intent_roundtrips_participant_accounts() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let mut intent = BroadcastIntent::new(replacement_test_transaction(10)); + intent.participant_accounts = + vec![OnchainWalletAccount { address_type: AddressType::Taproot, account_index: 7 }]; + intent.requires_outcome = true; + let key = intent.key(); + + node.wallet.write_broadcast_intent(&intent).unwrap(); + + assert_eq!(node.wallet.read_broadcast_intent_from_store(&key).unwrap(), Some(intent)); + } + + #[test] + fn cleanup_transition_waits_for_every_participant_account() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let mut intent = BroadcastIntent::new(replacement_test_transaction(11)); + intent.participant_accounts = vec![OnchainWalletAccount { + address_type: AddressType::NativeSegwit, + account_index: 7, + }]; + intent.begin_abandon().unwrap(); + let key = intent.key(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet.complete_broadcast_transitions().unwrap(); + + assert_eq!(node.wallet.read_broadcast_intent_from_store(&key).unwrap(), Some(intent)); + } + + #[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).unwrap(); + + 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).unwrap(); + assert!(node.wallet.take_locally_applied_unconfirmed_txids().is_empty()); + node.wallet.publish_locally_applied_unconfirmed(txid).unwrap(); + assert_eq!(node.wallet.take_locally_applied_unconfirmed_txids(), vec![txid]); + } + + #[test] + fn rbf_event_outbox_failures_keep_the_active_replacement_pending() { + let concrete_store = Arc::new(NamespaceFailStore::new()); + let store: Arc = concrete_store.clone(); + let node = replacement_test_node(store); + let original = replacement_test_transaction(41); + let replacement = replacement_test_transaction(42); + let replacement_txid = replacement.compute_txid(); + let intent = BroadcastIntent::replacement(None, original, replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.note_locally_applied_unconfirmed(replacement_txid).unwrap(); + + concrete_store.fail_next_write_in(crate::io::ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE); + assert_eq!( + node.wallet.clear_broadcast_intent(&replacement_txid), + Err(Error::PersistenceFailed) + ); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![replacement_txid]); + + concrete_store.fail_next_write_in(crate::io::ONCHAIN_BROADCAST_EVENT_PRIMARY_NAMESPACE); + assert_eq!( + node.wallet.publish_locally_applied_unconfirmed(replacement_txid), + Err(Error::PersistenceFailed) + ); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![replacement_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).unwrap(); + 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 rbf_reentry_from_predecessor_preserves_existing_lineage() { + let original = replacement_test_transaction(50); + let first_replacement = replacement_test_transaction(51); + let second_replacement = replacement_test_transaction(52); + let first_replacement_txid = first_replacement.compute_txid(); + let mut intent = + BroadcastIntent::replacement(None, original.clone(), first_replacement.clone()) + .unwrap(); + intent.mark_accepted(first_replacement_txid).unwrap(); + + let intent = + BroadcastIntent::replacement(Some(intent), original, second_replacement.clone()) + .unwrap(); + + assert_eq!(intent.transactions.len(), 3); + assert_eq!(intent.transactions[1], first_replacement); + assert_eq!(intent.active_transaction(), &second_replacement); + assert_eq!(intent.predecessor_indexes[2], 0); + } + + #[test] + fn accepted_lineage_observation_selects_latest_observed_member() { + let original = replacement_test_transaction(53); + let replacement = replacement_test_transaction(54); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::replacement(None, original, replacement).unwrap(); + intent.mark_accepted(replacement_txid).unwrap(); + let intents = vec![(intent.key(), intent.clone())]; + let observed = + intent.transactions.iter().map(Transaction::compute_txid).collect::>(); + + assert_eq!( + observed_broadcast_intents(&intents, &observed, &HashSet::new()), + vec![(intent.key(), replacement_txid, false)] + ); + } + + #[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 mut intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + intent.requires_outcome = true; + 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_write_in(ONCHAIN_BROADCAST_OUTCOME_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); + + 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 pending_broadcast_outcome_resolves_every_lineage_alias_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 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(); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::replacement(None, original, replacement).unwrap(); + intent.requires_outcome = true; + node.wallet.write_broadcast_intent(&intent).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + let expected = Some(( + BroadcastOutcomeStatus::Pending, + replacement_txid, + vec![original_txid, replacement_txid], + )); + assert_eq!(restarted.wallet.broadcast_outcome(&original_txid).unwrap(), expected); + assert_eq!(restarted.wallet.broadcast_outcome(&replacement_txid).unwrap(), expected); + } + + #[test] + fn accepted_broadcast_outcome_tracks_observed_and_confirmed_lineage_members() { + 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(40); + 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(41); + 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 mut intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + intent.requires_outcome = true; + let intent_key = intent.key(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet.clear_broadcast_intent(&replacement_txid).unwrap(); + node.wallet.resolve_broadcast_intents([(intent_key, original_txid, false)]).unwrap(); + let accepted_original = Some(( + BroadcastOutcomeStatus::Accepted, + original_txid, + vec![original_txid, replacement_txid], + )); + assert_eq!(node.wallet.broadcast_outcome(&original_txid).unwrap(), accepted_original); + assert_eq!(node.wallet.broadcast_outcome(&replacement_txid).unwrap(), accepted_original); + assert_eq!( + node.wallet.acknowledge_broadcast_outcome(&replacement_txid), + Err(Error::OnchainTxBroadcastFailed { txid: original_txid }) + ); + + let block = replacement_test_confirmation_block(original); + node.wallet.inner.lock().unwrap().apply_block(&block, 1).unwrap(); + node.wallet.resolve_broadcast_intents([(intent_key, original_txid, true)]).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert_eq!( + restarted.wallet.broadcast_outcome(&replacement_txid).unwrap(), + accepted_original + ); + restarted.wallet.acknowledge_broadcast_outcome(&replacement_txid).unwrap(); + assert_eq!(restarted.wallet.broadcast_outcome(&original_txid).unwrap(), None); + assert_eq!(restarted.wallet.broadcast_outcome(&replacement_txid).unwrap(), None); + restarted.wallet.acknowledge_broadcast_outcome(&replacement_txid).unwrap(); + } + + #[test] + fn abandoned_broadcast_outcome_survives_restart_until_acknowledged() { + 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(42); + 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(); + node.wallet.mark_broadcast_outcome_required(&txid).unwrap(); + + node.wallet.abandon_broadcast_intent_by_txid(&txid).unwrap(); + let abandoned = Some((BroadcastOutcomeStatus::Abandoned, txid, vec![txid])); + assert_eq!(node.wallet.broadcast_outcome(&txid).unwrap(), abandoned); + drop(node); + + let restarted = replacement_test_node(store); + assert_eq!(restarted.wallet.broadcast_outcome(&txid).unwrap(), abandoned); + restarted.wallet.acknowledge_broadcast_outcome(&txid).unwrap(); + assert_eq!(restarted.wallet.broadcast_outcome(&txid).unwrap(), None); + } + + #[test] + fn observation_before_unknown_result_upgrades_the_terminal_outcome() { + 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 tx = replacement_test_transaction(43); + 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.begin_broadcast_dispatch(txid).unwrap(); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + let intent_key = node.wallet.find_broadcast_intent_by_active_txid(&txid).unwrap().0; + + node.wallet.resolve_broadcast_intents([(intent_key, txid, false)]).unwrap(); + assert!(node.wallet.find_broadcast_intent_by_active_txid(&txid).is_none()); + node.wallet.mark_broadcast_outcome_required(&txid).unwrap(); + node.wallet.end_broadcast_dispatches(&[txid]); + + assert_eq!( + node.wallet.broadcast_outcome(&txid).unwrap(), + Some((BroadcastOutcomeStatus::Accepted, txid, vec![txid])) + ); + } + + #[test] + fn conclusive_broadcast_success_removes_its_transient_outcome() { + 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 tx = replacement_test_transaction(44); + 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.begin_broadcast_dispatch(txid).unwrap(); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + + node.wallet.clear_broadcast_intent(&txid).unwrap(); + node.wallet.end_broadcast_dispatches(&[txid]); + + assert_eq!(node.wallet.broadcast_outcome(&txid).unwrap(), None); + } + + #[test] + fn outcome_persistence_failures_remain_queryable_and_retryable() { + 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 tx = replacement_test_transaction(45); + 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(); + node.wallet.mark_broadcast_outcome_required(&txid).unwrap(); + + concrete_store.fail_next_write_in(ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.clear_broadcast_intent(&txid), Err(Error::PersistenceFailed)); + assert_eq!( + node.wallet.broadcast_outcome(&txid).unwrap(), + Some((BroadcastOutcomeStatus::Pending, txid, vec![txid])) + ); + node.wallet.clear_broadcast_intent(&txid).unwrap(); + + concrete_store.fail_next_list_in(ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.broadcast_outcome(&txid), Err(Error::PersistenceFailed)); + concrete_store.fail_next_read_in(ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.broadcast_outcome(&txid), Err(Error::PersistenceFailed)); + concrete_store.fail_next_remove_in(ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.acknowledge_broadcast_outcome(&txid), Err(Error::PersistenceFailed)); + assert_eq!( + node.wallet.broadcast_outcome(&txid).unwrap(), + Some((BroadcastOutcomeStatus::Accepted, txid, vec![txid])) + ); + } + + #[test] + fn abandoned_outcome_write_failure_keeps_the_transition_recoverable() { + 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 tx = replacement_test_transaction(46); + 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(); + node.wallet.mark_broadcast_outcome_required(&txid).unwrap(); + concrete_store.fail_next_write_in(ONCHAIN_BROADCAST_OUTCOME_PRIMARY_NAMESPACE); + + assert_eq!( + node.wallet.abandon_broadcast_intent_by_txid(&txid), + Err(Error::PersistenceFailed) + ); + let (_, abandoning) = node.wallet.find_broadcast_intent_by_active_txid(&txid).unwrap(); + assert_eq!(abandoning.state, BroadcastIntentState::Abandoning); + assert_eq!( + node.wallet.broadcast_outcome(&txid).unwrap(), + Some((BroadcastOutcomeStatus::Abandoned, txid, vec![txid])) + ); + } + + #[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_account_broadcast_intent_is_restored_with_conservative_outcome_retention() { + let store = Arc::new(InMemoryStore::new()); + let tx = replacement_test_transaction(47); + let txid = tx.compute_txid(); + let account = OnchainWalletAccount { address_type: AddressType::Taproot, account_index: 7 }; + let mut bytes = vec![LEGACY_ACCOUNT_BROADCAST_INTENT_SERIALIZATION_VERSION]; + bytes.extend(serialize(&( + 0u32, + BroadcastIntentState::Pending.serialization_tag(), + vec![super::NO_PREDECESSOR_INDEX], + vec![0u8], + vec![account.address_type.serialization_tag()], + vec![u64::from(account.account_index)], + vec![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); + let (_, intent) = node.wallet.find_broadcast_intent_by_active_txid(&txid).unwrap(); + assert_eq!(intent.participant_accounts, vec![account]); + assert!(intent.requires_outcome); + } + + #[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); + } + #[test] fn checked_sum_rejects_overflow() { assert_eq!(checked_sum([u64::MAX, 1]), Err(Error::InsufficientFunds)); @@ -2516,7 +5703,7 @@ mod tests { .assume_checked(); let fee_rate = bitcoin::FeeRate::from_sat_per_kwu(250); assert_eq!( - checked_payment_target(u64::MAX, recipient.script_pubkey(), None, fee_rate), + checked_payment_target(u64::MAX, &recipient.script_pubkey(), None, fee_rate), Err(Error::InsufficientFunds) ); let utxo = UtxoPsbtInfo { @@ -2526,7 +5713,7 @@ mod tests { is_primary: true, }; assert_eq!( - checked_payment_target(u64::MAX, recipient.script_pubkey(), Some(&[utxo]), fee_rate), + checked_payment_target(u64::MAX, &recipient.script_pubkey(), Some(&[utxo]), fee_rate), Err(Error::InsufficientFunds) ); } 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 01aaf977fc..cee65d4c58 100644 --- a/tests/multi_address_types_tests.rs +++ b/tests/multi_address_types_tests.rs @@ -1814,6 +1814,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, @@ -1895,7 +1896,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; @@ -1912,6 +1912,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!(