From 3ce094b33b3b65d35725f77587a71efa45526daa Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:42:37 +0200 Subject: [PATCH 01/30] fix: wait for on-chain broadcast results --- CHANGELOG.md | 6 +- Package.swift | 4 + .../ldknode/ldk_node.android.kt | 15 ++ .../ldknode/ldk_node.common.kt | 6 + bindings/ldk_node.udl | 3 + bindings/python/src/ldk_node/ldk_node.py | 39 +++++ bindings/swift/Sources/LDKNode/LDKNode.swift | 24 +++ crates/bdk-wallet-aggregate/src/lib.rs | 22 +++ src/chain/bitcoind.rs | 24 ++- src/chain/electrum.rs | 36 ++++- src/chain/esplora.rs | 93 ++++++----- src/chain/mod.rs | 15 +- src/error.rs | 15 ++ src/lib.rs | 4 + src/payment/onchain.rs | 38 ++++- src/tx_broadcaster.rs | 149 +++++++++++++++++- src/wallet/mod.rs | 8 +- 17 files changed, 434 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1abfe1ccac..8be6773c79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,4 @@ -# 0.7.0-rc.66 (Synonym Fork) - +# 0.7.0-rc.67 (Synonym Fork) ## Bug Fixes - Prevent native SIGABRT crashes when stopping and rebuilding the node by making runtime teardown deterministic. @@ -84,6 +83,9 @@ ## Synonym Fork Additions +- Explicit on-chain sends now return a transaction ID only after the configured backend accepts + the transaction, with distinct errors for rejection, failure, and timeout. +- Electrum transaction rejections are now logged as failures instead of successful broadcasts. - 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/Package.swift b/Package.swift index f8cc852f0e..5aabc8fc8e 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,11 @@ import PackageDescription let tag = "v0.7.0-rc.66" +<<<<<<< HEAD let checksum = "21ac13bfdc9fdd3099a688bd0053f8b14c74b5957943b2f09624886e65556a8e" +======= +let checksum = "3d4ed7123281235353cc7558317c3c15f94e0ff9adcab5fbb1fe35d572662f3d" +>>>>>>> 8fe28e1 (fix: wait for on-chain broadcast results) 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/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..92de69dca1 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 @@ -12200,6 +12200,9 @@ object FfiConverterTypeNodeError : FfiConverterRustBuffer { 66 -> NodeException.AddressTypeNotMonitored(FfiConverterString.read(buf)) 67 -> NodeException.OnchainWalletAccountNotRegistered(FfiConverterString.read(buf)) 68 -> NodeException.InvalidSeedBytes(FfiConverterString.read(buf)) + 69 -> NodeException.OnchainTxBroadcastRejected(FfiConverterString.read(buf)) + 70 -> NodeException.OnchainTxBroadcastFailed(FfiConverterString.read(buf)) + 71 -> NodeException.OnchainTxBroadcastTimeout(FfiConverterString.read(buf)) else -> throw RuntimeException("invalid error enum value, something is very wrong!!") } } @@ -12482,6 +12485,18 @@ object FfiConverterTypeNodeError : FfiConverterRustBuffer { buf.putInt(68) Unit } + is NodeException.OnchainTxBroadcastRejected -> { + buf.putInt(69) + Unit + } + is NodeException.OnchainTxBroadcastFailed -> { + buf.putInt(70) + Unit + } + is NodeException.OnchainTxBroadcastTimeout -> { + buf.putInt(71) + Unit + } }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } } } 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..79772e30ef 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 @@ -2025,6 +2025,12 @@ sealed class NodeException(message: String): kotlin.Exception(message) { class InvalidSeedBytes(message: String) : NodeException(message) + class OnchainTxBroadcastRejected(message: String) : NodeException(message) + + class OnchainTxBroadcastFailed(message: String) : NodeException(message) + + class OnchainTxBroadcastTimeout(message: String) : NodeException(message) + } diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 9cdcdc85b4..33375e896f 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -504,6 +504,9 @@ enum NodeError { "AddressTypeNotMonitored", "OnchainWalletAccountNotRegistered", "InvalidSeedBytes", + "OnchainTxBroadcastRejected", + "OnchainTxBroadcastFailed", + "OnchainTxBroadcastTimeout", }; dictionary NodeStatus { diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index 0dda20db99..1fe59b69b3 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -13520,6 +13520,21 @@ class InvalidSeedBytes(_UniffiTempNodeError): def __repr__(self): return "NodeError.InvalidSeedBytes({})".format(repr(str(self))) _UniffiTempNodeError.InvalidSeedBytes = InvalidSeedBytes # type: ignore + class OnchainTxBroadcastRejected(_UniffiTempNodeError): + + def __repr__(self): + return "NodeError.OnchainTxBroadcastRejected({})".format(repr(str(self))) + _UniffiTempNodeError.OnchainTxBroadcastRejected = OnchainTxBroadcastRejected # type: ignore + class OnchainTxBroadcastFailed(_UniffiTempNodeError): + + def __repr__(self): + return "NodeError.OnchainTxBroadcastFailed({})".format(repr(str(self))) + _UniffiTempNodeError.OnchainTxBroadcastFailed = OnchainTxBroadcastFailed # type: ignore + class OnchainTxBroadcastTimeout(_UniffiTempNodeError): + + def __repr__(self): + return "NodeError.OnchainTxBroadcastTimeout({})".format(repr(str(self))) + _UniffiTempNodeError.OnchainTxBroadcastTimeout = OnchainTxBroadcastTimeout # type: ignore NodeError = _UniffiTempNodeError # type: ignore del _UniffiTempNodeError @@ -13801,6 +13816,18 @@ def read(buf): return NodeError.InvalidSeedBytes( _UniffiConverterString.read(buf), ) + if variant == 69: + return NodeError.OnchainTxBroadcastRejected( + _UniffiConverterString.read(buf), + ) + if variant == 70: + return NodeError.OnchainTxBroadcastFailed( + _UniffiConverterString.read(buf), + ) + if variant == 71: + return NodeError.OnchainTxBroadcastTimeout( + _UniffiConverterString.read(buf), + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod @@ -13941,6 +13968,12 @@ def check_lower(value): return if isinstance(value, NodeError.InvalidSeedBytes): return + if isinstance(value, NodeError.OnchainTxBroadcastRejected): + return + if isinstance(value, NodeError.OnchainTxBroadcastFailed): + return + if isinstance(value, NodeError.OnchainTxBroadcastTimeout): + return @staticmethod def write(value, buf): @@ -14080,6 +14113,12 @@ 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) + if isinstance(value, NodeError.OnchainTxBroadcastFailed): + buf.write_i32(70) + if isinstance(value, NodeError.OnchainTxBroadcastTimeout): + buf.write_i32(71) diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index c58b0d716a..c1be1a9091 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -9313,6 +9313,12 @@ public enum NodeError { case OnchainWalletAccountNotRegistered(message: String) case InvalidSeedBytes(message: String) + + case OnchainTxBroadcastRejected(message: String) + + case OnchainTxBroadcastFailed(message: String) + + case OnchainTxBroadcastTimeout(message: String) } #if swift(>=5.8) @@ -9596,6 +9602,18 @@ public struct FfiConverterTypeNodeError: FfiConverterRustBuffer { message: FfiConverterString.read(from: &buf) ) + case 69: return try .OnchainTxBroadcastRejected( + message: FfiConverterString.read(from: &buf) + ) + + case 70: return try .OnchainTxBroadcastFailed( + message: FfiConverterString.read(from: &buf) + ) + + case 71: return try .OnchainTxBroadcastTimeout( + message: FfiConverterString.read(from: &buf) + ) + default: throw UniffiInternalError.unexpectedEnumCase } } @@ -9738,6 +9756,12 @@ public struct FfiConverterTypeNodeError: FfiConverterRustBuffer { writeInt(&buf, Int32(67)) case .InvalidSeedBytes(_ /* message is ignored*/ ): writeInt(&buf, Int32(68)) + case .OnchainTxBroadcastRejected(_ /* message is ignored*/ ): + writeInt(&buf, Int32(69)) + case .OnchainTxBroadcastFailed(_ /* message is ignored*/ ): + writeInt(&buf, Int32(70)) + case .OnchainTxBroadcastTimeout(_ /* message is ignored*/ ): + writeInt(&buf, Int32(71)) } } } diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs index 6215359f17..f30cb957b4 100644 --- a/crates/bdk-wallet-aggregate/src/lib.rs +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -1839,6 +1839,28 @@ mod tests { ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20])) } + #[test] + fn signed_transaction_is_not_recorded_until_observed_on_chain() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + aggregate.persist_all().unwrap(); + + let txid = tx.compute_txid(); + assert!(aggregate + .wallets() + .values() + .flat_map(|wallet| wallet.transactions()) + .all(|wallet_tx| wallet_tx.tx_node.txid != txid)); + } + #[test] fn persistence_fails_when_a_wallet_has_no_persister() { let mut persister = NoopPersister; diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 7467af86f7..956746cd96 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -38,6 +38,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::TxBroadcastError; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; @@ -643,30 +644,44 @@ 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); + Ok(()) }, Err(e) => { + let result = if e + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + .is_some() + { + Err(TxBroadcastError::Rejected) + } else { + Err(TxBroadcastError::Failed) + }; 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 +696,14 @@ impl BitcoindChainSource { "Failed broadcast transaction bytes: {}", log_bytes!(tx.encode()) ); + Err(TxBroadcastError::Timeout) }, + }; + if package_result.is_ok() { + package_result = tx_result; } } + package_result } } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 2e8a8cb181..a2560d65bc 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -41,6 +41,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::TxBroadcastError; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::NodeMetrics; @@ -524,18 +525,25 @@ impl ElectrumChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_broadcast_package( + &self, package: Vec, + ) -> Result<(), TxBroadcastError> { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().unwrap().client().as_ref() { Arc::clone(client) } else { debug_assert!(false, "We should have started the chain source before broadcasting"); - return; + return Err(TxBroadcastError::Failed); }; + let mut package_result = Ok(()); for tx in package { - electrum_client.broadcast(tx).await; + let result = electrum_client.broadcast(tx).await; + if package_result.is_ok() { + package_result = result; + } } + package_result } pub(super) async fn get_address_balance(&self, address: &bitcoin::Address) -> Option { @@ -831,7 +839,7 @@ impl ElectrumRuntimeClient { }) } - async fn broadcast(&self, tx: Transaction) { + async fn broadcast(&self, tx: Transaction) -> Result<(), TxBroadcastError> { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); @@ -844,8 +852,24 @@ impl ElectrumRuntimeClient { match timeout_fut.await { Ok(res) => match res { - Ok(_) => { + Ok(Ok(id)) => { + debug_assert_eq!(id, txid); log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + Ok(()) + }, + Ok(Err(e)) => { + let result = if matches!(&e, electrum_client::Error::Protocol(_)) { + Err(TxBroadcastError::Rejected) + } else { + Err(TxBroadcastError::Failed) + }; + log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx_bytes) + ); + result }, Err(e) => { log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); @@ -854,6 +878,7 @@ impl ElectrumRuntimeClient { "Failed broadcast transaction bytes: {}", log_bytes!(tx_bytes) ); + Err(TxBroadcastError::Failed) }, }, Err(e) => { @@ -868,6 +893,7 @@ impl ElectrumRuntimeClient { "Failed broadcast transaction bytes: {}", log_bytes!(tx_bytes) ); + Err(TxBroadcastError::Timeout) }, } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 8de1a23962..598d3ac99a 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::TxBroadcastError; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; @@ -479,58 +480,73 @@ 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. + Err(e) => { + let result = if matches!( + &e, + esplora_client::Error::HttpResponse { status: 400, .. } + ) { + Err(TxBroadcastError::Rejected) + } else { + Err(TxBroadcastError::Failed) + }; + 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 to broadcast due to HTTP connection error: {}", - message + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) ); - } else { + }, + _ => { log_error!( self.logger, - "Failed to broadcast due to HTTP connection error: {} - {}", - status, - message + "Failed to broadcast transaction {}: {}", + txid, + e ); - } - 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()) - ); - }, + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + }, + } + result }, }, Err(e) => { @@ -545,9 +561,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 { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 4a20065f59..1ec208b363 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1306,17 +1306,22 @@ impl ChainSource { ); return; } - Some(next_package) = receiver.recv() => { - match &self.kind { + Some(request) = receiver.recv() => { + let package = request.package; + let result_sender = request.result_sender; + let result = match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_broadcast_package(package).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_broadcast_package(package).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_broadcast_package(package).await }, + }; + if let Some(result_sender) = result_sender { + let _ = result_sender.send(result); } } } diff --git a/src/error.rs b/src/error.rs index c285162ca6..b8a9bd34f5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -156,6 +156,12 @@ 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, + /// Broadcasting an on-chain transaction failed before backend acceptance was known. + OnchainTxBroadcastFailed, + /// Broadcasting an on-chain transaction timed out before backend acceptance was known. + OnchainTxBroadcastTimeout, } impl fmt::Display for Error { @@ -264,6 +270,15 @@ impl fmt::Display for Error { Self::InvalidSeedBytes => { write!(f, "The seed bytes or seed-derived wallet account are invalid.") }, + Self::OnchainTxBroadcastRejected => { + write!(f, "The on-chain transaction was rejected by the configured chain backend.") + }, + Self::OnchainTxBroadcastFailed => { + write!(f, "Failed to broadcast the on-chain transaction.") + }, + Self::OnchainTxBroadcastTimeout => { + write!(f, "Broadcasting the on-chain transaction timed out.") + }, } } } diff --git a/src/lib.rs b/src/lib.rs index 5b51005963..46bdfe81b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1112,6 +1112,8 @@ impl Node { pub fn onchain_payment(&self) -> OnchainPayment { OnchainPayment::new( Arc::clone(&self.wallet), + Arc::clone(&self.tx_broadcaster), + Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), @@ -1124,6 +1126,8 @@ impl Node { pub fn onchain_payment(&self) -> Arc { Arc::new(OnchainPayment::new( Arc::clone(&self.wallet), + Arc::clone(&self.tx_broadcaster), + Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index ef30c0279a..acb2b40af6 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -15,7 +15,8 @@ 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::runtime::Runtime; +use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; #[cfg(not(feature = "uniffi"))] @@ -98,6 +99,8 @@ impl From for AddressInfo { /// [`Node::onchain_payment`]: crate::Node::onchain_payment pub struct OnchainPayment { wallet: Arc, + tx_broadcaster: Arc, + runtime: Arc, channel_manager: Arc, config: Arc, is_running: Arc>, @@ -106,10 +109,11 @@ pub struct OnchainPayment { impl OnchainPayment { pub(crate) fn new( - wallet: Arc, channel_manager: Arc, config: Arc, - is_running: Arc>, logger: Arc, + wallet: Arc, tx_broadcaster: Arc, runtime: Arc, + channel_manager: Arc, config: Arc, is_running: Arc>, + logger: Arc, ) -> Self { - Self { wallet, channel_manager, config, is_running, logger } + Self { wallet, tx_broadcaster, runtime, channel_manager, config, is_running, logger } } /// Retrieve a new on-chain/funding address. @@ -538,6 +542,10 @@ 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 chain backend accepts the transaction. + /// A rejection, transport failure, or timeout is returned as an error. For transport failures + /// and timeouts, backend acceptance remains unknown and callers must not present success. + /// /// [`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, @@ -553,13 +561,16 @@ 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, - ) + )?; + let txid = tx.compute_txid(); + self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx))?; + Ok(txid) } /// Send an on-chain payment to the given address, draining the available funds. @@ -578,6 +589,10 @@ 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 chain backend accepts the transaction. + /// A rejection, transport failure, or timeout is returned as an error. For transport failures + /// and timeouts, backend acceptance remains unknown and callers must not present success. + /// /// [`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( @@ -596,7 +611,16 @@ 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, + )?; + let txid = tx.compute_txid(); + self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx))?; + Ok(txid) } /// Bumps the fee of an existing transaction using Replace-By-Fee (RBF). diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 12a1fe650c..70c1d75d86 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,21 +6,46 @@ // accordance with one or both of these licenses. use std::ops::Deref; +use std::time::Duration; use bitcoin::Transaction; use lightning::chain::chaininterface::BroadcasterInterface; -use tokio::sync::{mpsc, Mutex, MutexGuard}; +use tokio::sync::{mpsc, oneshot, Mutex, MutexGuard}; +use crate::config::TX_BROADCAST_TIMEOUT_SECS; +use crate::error::Error; use crate::logger::{log_error, LdkLogger}; const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TxBroadcastError { + Rejected, + Failed, + Timeout, +} + +impl From for Error { + fn from(error: TxBroadcastError) -> Self { + match error { + TxBroadcastError::Rejected => Error::OnchainTxBroadcastRejected, + TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed, + TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout, + } + } +} + +pub(crate) struct BroadcastRequest { + pub(crate) package: Vec, + pub(crate) result_sender: Option>>, +} + pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + queue_sender: mpsc::Sender, + queue_receiver: Mutex>, logger: L, } @@ -35,9 +60,28 @@ where pub(crate) async fn get_broadcast_queue( &self, - ) -> MutexGuard<'_, mpsc::Receiver>> { + ) -> MutexGuard<'_, mpsc::Receiver> { self.queue_receiver.lock().await } + + pub(crate) async fn broadcast_transaction(&self, tx: Transaction) -> Result<(), Error> { + self.broadcast_transaction_with_timeout(tx, Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS)) + .await + } + + async fn broadcast_transaction_with_timeout( + &self, tx: Transaction, timeout: Duration, + ) -> Result<(), Error> { + let (result_sender, result_receiver) = oneshot::channel(); + let request = BroadcastRequest { package: vec![tx], result_sender: Some(result_sender) }; + let result = tokio::time::timeout(timeout, async { + self.queue_sender.send(request).await.map_err(|_| TxBroadcastError::Failed)?; + result_receiver.await.map_err(|_| TxBroadcastError::Failed)? + }) + .await + .map_err(|_| TxBroadcastError::Timeout)?; + result.map_err(Into::into) + } } impl BroadcasterInterface for TransactionBroadcaster @@ -46,8 +90,103 @@ where { fn broadcast_transactions(&self, txs: &[&Transaction]) { let package = txs.iter().map(|&t| t.clone()).collect::>(); - self.queue_sender.try_send(package).unwrap_or_else(|e| { + let request = BroadcastRequest { package, result_sender: None }; + self.queue_sender.try_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::{TransactionBroadcaster, TxBroadcastError}; + use crate::Error; + + fn test_transaction() -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + } + } + + #[tokio::test] + async fn explicit_broadcast_returns_after_backend_acceptance() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); + let process_fut = async { + let mut receiver = broadcaster.get_broadcast_queue().await; + let request = receiver.recv().await.unwrap(); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn explicit_broadcast_propagates_backend_rejection() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); + let process_fut = async { + let mut receiver = broadcaster.get_broadcast_queue().await; + let request = receiver.recv().await.unwrap(); + request.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Err(Error::OnchainTxBroadcastRejected)); + } + + #[tokio::test] + async fn explicit_broadcast_propagates_backend_failure() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); + let process_fut = async { + let mut receiver = broadcaster.get_broadcast_queue().await; + let request = receiver.recv().await.unwrap(); + request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Err(Error::OnchainTxBroadcastFailed)); + } + + #[tokio::test] + async fn explicit_broadcast_times_out_without_backend_result() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let broadcast_fut = broadcaster + .broadcast_transaction_with_timeout(test_transaction(), Duration::from_millis(10)); + let process_fut = async { + let mut receiver = broadcaster.get_broadcast_queue().await; + let request = receiver.recv().await.unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + drop(request); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Err(Error::OnchainTxBroadcastTimeout)); + } + + #[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 receiver = broadcaster.get_broadcast_queue().await; + let request = receiver.recv().await.unwrap(); + assert_eq!(request.package, vec![tx]); + assert!(request.result_sender.is_none()); + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 26fde71f80..edad41bc9c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1859,10 +1859,10 @@ impl Wallet { } #[allow(deprecated)] - pub(crate) fn send_to_address( + pub(crate) fn create_send_to_address_transaction( &self, address: &Address, send_amount: OnchainSendAmount, fee_rate: Option, utxos_to_spend: Option>, channel_manager: &ChannelManager, - ) -> Result { + ) -> Result { self.parse_and_validate_address(&address)?; // Use the set fee_rate or default to fee estimation. @@ -1912,8 +1912,6 @@ impl Wallet { tx }; - self.broadcaster.broadcast_transactions(&[&tx]); - let txid = tx.compute_txid(); match send_amount { @@ -1945,7 +1943,7 @@ impl Wallet { }, } - Ok(txid) + Ok(tx) } pub(crate) fn select_confirmed_utxos( From eb11f2131d4e0878441783b9c43317a8c45265b4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:10:27 +0200 Subject: [PATCH 02/30] fix: classify broadcast backend results --- Cargo.toml | 2 +- Package.swift | 5 + .../kotlin/ldk-node-android/gradle.properties | 2 +- bindings/python/pyproject.toml | 2 +- src/chain/bitcoind.rs | 106 +++++++++++++--- src/chain/electrum.rs | 117 +++++++++++++++--- src/chain/esplora.rs | 109 +++++++++------- src/tx_broadcaster.rs | 86 ++++++++++++- 8 files changed, 346 insertions(+), 83 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aff8af778d..ff9c863010 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["bindings/uniffi-bindgen"] [package] name = "ldk-node" -version = "0.7.0-rc.66" +version = "0.7.0-rc.67" authors = ["Elias Rohrer "] homepage = "https://lightningdevkit.org/" license = "MIT OR Apache-2.0" diff --git a/Package.swift b/Package.swift index 5aabc8fc8e..914b83d5a7 100644 --- a/Package.swift +++ b/Package.swift @@ -3,12 +3,17 @@ import PackageDescription +<<<<<<< HEAD let tag = "v0.7.0-rc.66" <<<<<<< HEAD let checksum = "21ac13bfdc9fdd3099a688bd0053f8b14c74b5957943b2f09624886e65556a8e" ======= let checksum = "3d4ed7123281235353cc7558317c3c15f94e0ff9adcab5fbb1fe35d572662f3d" >>>>>>> 8fe28e1 (fix: wait for on-chain broadcast results) +======= +let tag = "v0.7.0-rc.67" +let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" +>>>>>>> 049b116 (fix: classify broadcast backend results) 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/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/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 956746cd96..a4699d92d4 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -38,7 +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::TxBroadcastError; +use crate::tx_broadcaster::{ + classify_rpc_broadcast_error, validate_broadcast_txid, TxBroadcastError, +}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; @@ -661,26 +663,40 @@ impl BitcoindChainSource { 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); - Ok(()) + 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) => { - let result = if e - .get_ref() - .and_then(|inner| inner.downcast_ref::()) - .is_some() - { - Err(TxBroadcastError::Rejected) + let result = classify_bitcoind_broadcast_error(&e); + if result.is_ok() { + log_trace!( + self.logger, + "Transaction {} is already known by backend", + txid + ); } else { - Err(TxBroadcastError::Failed) - }; - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - 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 }, }, @@ -707,6 +723,19 @@ impl BitcoindChainSource { } } +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), + } +} + struct MempoolUpdate { transactions: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, @@ -1627,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; @@ -1634,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}; @@ -1641,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 a2560d65bc..3b51abd909 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -41,7 +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::TxBroadcastError; +use crate::tx_broadcaster::{ + classify_rpc_broadcast_error, validate_broadcast_txid, TxBroadcastError, +}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::NodeMetrics; @@ -852,23 +854,43 @@ impl ElectrumRuntimeClient { match timeout_fut.await { Ok(res) => match res { - Ok(Ok(id)) => { - debug_assert_eq!(id, txid); - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); - Ok(()) - }, - Ok(Err(e)) => { - let result = if matches!(&e, electrum_client::Error::Protocol(_)) { - Err(TxBroadcastError::Rejected) - } else { - Err(TxBroadcastError::Failed) - }; - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); + 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) => { @@ -983,6 +1005,7 @@ impl ElectrumRuntimeClient { } } +<<<<<<< HEAD struct ConfirmGate { active: AtomicBool, } @@ -1065,6 +1088,18 @@ impl Confirm for ShutdownAwareConfirm { self.with_confirmables(Vec::new(), |confirmables| { confirmables.iter().flat_map(|confirmable| confirmable.get_relevant_txids()).collect() }) +======= +fn classify_electrum_broadcast_result( + expected_txid: Txid, result: &Result, +) -> Result<(), TxBroadcastError> { + match result { + Ok(returned_txid) => validate_broadcast_txid(expected_txid, *returned_txid), + Err(electrum_client::Error::Protocol(value)) => { + let code = value.get("code").and_then(serde_json::Value::as_i64); + classify_rpc_broadcast_error(code, &value.to_string()) + }, + Err(_) => Err(TxBroadcastError::Failed), +>>>>>>> 049b116 (fix: classify broadcast backend results) } } @@ -1174,6 +1209,52 @@ 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 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 598d3ac99a..10625f0e0f 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -29,7 +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::TxBroadcastError; +use crate::tx_broadcaster::{classify_rpc_broadcast_error, TxBroadcastError}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; @@ -497,54 +497,42 @@ impl EsploraChainSource { Ok(()) }, Err(e) => { - let result = if matches!( - &e, - esplora_client::Error::HttpResponse { status: 400, .. } - ) { - Err(TxBroadcastError::Rejected) + let result = classify_esplora_broadcast_error(&e); + if result.is_ok() { + log_trace!( + self.logger, + "Transaction {} is already known by backend", + txid + ); } else { - Err(TxBroadcastError::Failed) - }; - 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!( + match e { + esplora_client::Error::HttpResponse { status, message } => { + log_error!( self.logger, - "Failed to broadcast due to HTTP connection error: {}", + "Failed to broadcast due to HTTP response: {} - {}", + status, message ); - } else { + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + }, + _ => { log_error!( self.logger, - "Failed to broadcast due to HTTP connection error: {} - {}", - status, - message + "Failed to broadcast transaction {}: {}", + txid, + e ); - } - 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()) - ); - }, + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx.encode()) + ); + }, + } } result }, @@ -597,6 +585,15 @@ impl EsploraChainSource { } } +fn classify_esplora_broadcast_error(error: &esplora_client::Error) -> Result<(), TxBroadcastError> { + match error { + esplora_client::Error::HttpResponse { status: 400, message } => { + classify_rpc_broadcast_error(None, message) + }, + _ => Err(TxBroadcastError::Failed), + } +} + impl Filter for EsploraChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { self.tx_sync.register_tx(txid, script_pubkey); @@ -605,3 +602,31 @@ impl Filter for EsploraChainSource { self.tx_sync.register_output(output); } } + +#[cfg(test)] +mod tests { + use super::classify_esplora_broadcast_error; + use crate::tx_broadcaster::TxBroadcastError; + + #[test] + fn esplora_http_responses_distinguish_known_rejections_and_failures() { + let already_known = esplora_client::Error::HttpResponse { + status: 400, + message: "Transaction already in block chain".to_string(), + }; + assert_eq!(classify_esplora_broadcast_error(&already_known), Ok(())); + + let rejected = esplora_client::Error::HttpResponse { + status: 400, + message: r#"sendrawtransaction RPC error: {"code":-26,"message":"non-final"}"# + .to_string(), + }; + assert_eq!(classify_esplora_broadcast_error(&rejected), Err(TxBroadcastError::Rejected)); + + let unavailable = esplora_client::Error::HttpResponse { + status: 503, + message: "service unavailable".to_string(), + }; + assert_eq!(classify_esplora_broadcast_error(&unavailable), Err(TxBroadcastError::Failed)); + } +} diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 70c1d75d86..678e3cb204 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -8,7 +8,7 @@ use std::ops::Deref; use std::time::Duration; -use bitcoin::Transaction; +use bitcoin::{Transaction, Txid}; use lightning::chain::chaininterface::BroadcasterInterface; use tokio::sync::{mpsc, oneshot, Mutex, MutexGuard}; @@ -25,6 +25,68 @@ pub(crate) enum TxBroadcastError { Timeout, } +pub(crate) fn classify_rpc_broadcast_error( + code: Option, message: &str, +) -> Result<(), TxBroadcastError> { + let normalized_message = message.to_ascii_lowercase(); + let compact_message = normalized_message + .chars() + .filter(|character| !character.is_ascii_whitespace()) + .collect::(); + let contains_code = |candidate| { + code == Some(candidate) + || compact_message.contains(&format!("\"code\":{}", candidate)) + || normalized_message.contains(&format!("rpc error {}", candidate)) + }; + + if contains_code(-27) + || [ + "already in block chain", + "already in blockchain", + "already in mempool", + "transaction already known", + "txn-already-known", + ] + .iter() + .any(|marker| normalized_message.contains(marker)) + { + return Ok(()); + } + + if contains_code(-25) + || contains_code(-26) + || [ + "bad-txns-", + "dust", + "insufficient fee", + "mandatory-script-verify-flag-failed", + "mempool min fee not met", + "min relay fee not met", + "missing inputs", + "non-bip68-final", + "non-final", + "non-mandatory-script-verify-flag", + "txn-mempool-conflict", + ] + .iter() + .any(|marker| normalized_message.contains(marker)) + { + return Err(TxBroadcastError::Rejected); + } + + Err(TxBroadcastError::Failed) +} + +pub(crate) fn validate_broadcast_txid( + expected_txid: Txid, returned_txid: Txid, +) -> Result<(), TxBroadcastError> { + if returned_txid == expected_txid { + Ok(()) + } else { + Err(TxBroadcastError::Failed) + } +} + impl From for Error { fn from(error: TxBroadcastError) -> Self { match error { @@ -108,7 +170,7 @@ mod tests { use lightning::chain::chaininterface::BroadcasterInterface; use lightning::util::test_utils::TestLogger; - use super::{TransactionBroadcaster, TxBroadcastError}; + use super::{classify_rpc_broadcast_error, TransactionBroadcaster, TxBroadcastError}; use crate::Error; fn test_transaction() -> Transaction { @@ -120,6 +182,26 @@ mod tests { } } + #[test] + fn rpc_broadcast_errors_distinguish_known_rejections_and_ambiguous_failures() { + assert_eq!( + classify_rpc_broadcast_error(Some(-27), "Transaction already in block chain"), + Ok(()) + ); + assert_eq!( + classify_rpc_broadcast_error(None, r#"sendrawtransaction: {"code": -26}"#), + Err(TxBroadcastError::Rejected) + ); + assert_eq!( + classify_rpc_broadcast_error(None, "non-final"), + Err(TxBroadcastError::Rejected) + ); + assert_eq!( + classify_rpc_broadcast_error(Some(-28), "Loading block index"), + Err(TxBroadcastError::Failed) + ); + } + #[tokio::test] async fn explicit_broadcast_returns_after_backend_acceptance() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); From 42f01517b1731cbc1463ae23f39336f23ccf4166 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:33:35 +0200 Subject: [PATCH 03/30] fix: prioritize safety-critical broadcasts --- src/chain/mod.rs | 4 +- src/tx_broadcaster.rs | 105 +++++++++++++++++++++++++++++++----------- 2 files changed, 81 insertions(+), 28 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 1ec208b363..32b3b01c66 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1295,7 +1295,7 @@ impl ChainSource { pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { - let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; + let mut receivers = self.tx_broadcaster.get_broadcast_queue_receivers().await; loop { let tx_bcast_logger = Arc::clone(&self.logger); tokio::select! { @@ -1306,7 +1306,7 @@ impl ChainSource { ); return; } - Some(request) = receiver.recv() => { + Some(request) = receivers.recv() => { let package = request.package; let result_sender = request.result_sender; let result = match &self.kind { diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 678e3cb204..649450fd82 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -16,7 +16,7 @@ use crate::config::TX_BROADCAST_TIMEOUT_SECS; use crate::error::Error; use crate::logger::{log_error, LdkLogger}; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +const EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE: usize = 50; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TxBroadcastError { @@ -102,12 +102,30 @@ pub(crate) struct BroadcastRequest { pub(crate) result_sender: Option>>, } +/// Separate receivers for safety-critical LDK traffic and bounded explicit user sends. +pub(crate) struct BroadcastQueueReceivers { + ldk_receiver: mpsc::UnboundedReceiver, + explicit_receiver: mpsc::Receiver, +} + +impl BroadcastQueueReceivers { + /// Returns the next request, prioritizing LDK traffic when both queues are ready. + pub(crate) async fn recv(&mut self) -> Option { + tokio::select! { + biased; + request = self.ldk_receiver.recv() => request, + request = self.explicit_receiver.recv() => request, + } + } +} + pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender, - queue_receiver: Mutex>, + ldk_sender: mpsc::UnboundedSender, + explicit_sender: mpsc::Sender, + queue_receivers: Mutex, logger: L, } @@ -116,14 +134,17 @@ where L::Target: LdkLogger, { pub(crate) fn new(logger: L) -> Self { - let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { queue_sender, queue_receiver: Mutex::new(queue_receiver), logger } + let (ldk_sender, ldk_receiver) = mpsc::unbounded_channel(); + let (explicit_sender, explicit_receiver) = mpsc::channel(EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE); + let queue_receivers = + Mutex::new(BroadcastQueueReceivers { ldk_receiver, explicit_receiver }); + Self { ldk_sender, explicit_sender, queue_receivers, logger } } - pub(crate) async fn get_broadcast_queue( + pub(crate) async fn get_broadcast_queue_receivers( &self, - ) -> MutexGuard<'_, mpsc::Receiver> { - self.queue_receiver.lock().await + ) -> MutexGuard<'_, BroadcastQueueReceivers> { + self.queue_receivers.lock().await } pub(crate) async fn broadcast_transaction(&self, tx: Transaction) -> Result<(), Error> { @@ -136,12 +157,11 @@ where ) -> Result<(), Error> { let (result_sender, result_receiver) = oneshot::channel(); let request = BroadcastRequest { package: vec![tx], result_sender: Some(result_sender) }; - let result = tokio::time::timeout(timeout, async { - self.queue_sender.send(request).await.map_err(|_| TxBroadcastError::Failed)?; - result_receiver.await.map_err(|_| TxBroadcastError::Failed)? - }) - .await - .map_err(|_| TxBroadcastError::Timeout)?; + self.explicit_sender.try_send(request).map_err(|_| TxBroadcastError::Failed)?; + let result = tokio::time::timeout(timeout, result_receiver) + .await + .map_err(|_| TxBroadcastError::Timeout)? + .map_err(|_| TxBroadcastError::Failed)?; result.map_err(Into::into) } } @@ -153,7 +173,7 @@ where fn broadcast_transactions(&self, txs: &[&Transaction]) { let package = txs.iter().map(|&t| t.clone()).collect::>(); let request = BroadcastRequest { package, result_sender: None }; - self.queue_sender.try_send(request).unwrap_or_else(|e| { + self.ldk_sender.send(request).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } @@ -170,7 +190,10 @@ mod tests { use lightning::chain::chaininterface::BroadcasterInterface; use lightning::util::test_utils::TestLogger; - use super::{classify_rpc_broadcast_error, TransactionBroadcaster, TxBroadcastError}; + use super::{ + classify_rpc_broadcast_error, BroadcastRequest, TransactionBroadcaster, TxBroadcastError, + EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE, + }; use crate::Error; fn test_transaction() -> Transaction { @@ -207,8 +230,8 @@ mod tests { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); let process_fut = async { - let mut receiver = broadcaster.get_broadcast_queue().await; - let request = receiver.recv().await.unwrap(); + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); request.result_sender.unwrap().send(Ok(())).unwrap(); }; @@ -221,8 +244,8 @@ mod tests { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); let process_fut = async { - let mut receiver = broadcaster.get_broadcast_queue().await; - let request = receiver.recv().await.unwrap(); + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); request.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); }; @@ -235,8 +258,8 @@ mod tests { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); let process_fut = async { - let mut receiver = broadcaster.get_broadcast_queue().await; - let request = receiver.recv().await.unwrap(); + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); }; @@ -250,8 +273,8 @@ mod tests { let broadcast_fut = broadcaster .broadcast_transaction_with_timeout(test_transaction(), Duration::from_millis(10)); let process_fut = async { - let mut receiver = broadcaster.get_broadcast_queue().await; - let request = receiver.recv().await.unwrap(); + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); tokio::time::sleep(Duration::from_millis(20)).await; drop(request); }; @@ -266,9 +289,39 @@ mod tests { let tx = test_transaction(); broadcaster.broadcast_transactions(&[&tx]); - let mut receiver = broadcaster.get_broadcast_queue().await; - let request = receiver.recv().await.unwrap(); + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); assert_eq!(request.package, vec![tx]); assert!(request.result_sender.is_none()); } + + #[tokio::test] + async fn ldk_broadcast_is_not_dropped_when_explicit_queue_is_saturated() { + let broadcaster = TransactionBroadcaster::new(Arc::new(TestLogger::new())); + for _ in 0..EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE { + let (result_sender, _result_receiver) = tokio::sync::oneshot::channel(); + broadcaster + .explicit_sender + .try_send(BroadcastRequest { + package: vec![test_transaction()], + result_sender: Some(result_sender), + }) + .unwrap(); + } + + assert_eq!( + broadcaster + .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)) + .await, + Err(Error::OnchainTxBroadcastFailed) + ); + + 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()); + } } From 7d5e4750b83b42db273281113bb4668cdefd8d12 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 03:53:37 +0200 Subject: [PATCH 04/30] chore: refresh broadcast result artifacts --- Package.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Package.swift b/Package.swift index 914b83d5a7..32d8fcd3f9 100644 --- a/Package.swift +++ b/Package.swift @@ -12,8 +12,12 @@ let checksum = "3d4ed7123281235353cc7558317c3c15f94e0ff9adcab5fbb1fe35d572662f3d >>>>>>> 8fe28e1 (fix: wait for on-chain broadcast results) ======= let tag = "v0.7.0-rc.67" +<<<<<<< HEAD let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" >>>>>>> 049b116 (fix: classify broadcast backend results) +======= +let checksum = "ddbf716ae6d1bdaad8b10fa78abab7af4011956e4eb4d4b002f229dcb94f6c8c" +>>>>>>> d7f3bfc (chore: refresh broadcast result artifacts) let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( From 37064e5ec608a4e8b386a95716fe40f19d24933d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 10:59:09 +0200 Subject: [PATCH 05/30] test: cover wire-level electrum rejection --- src/chain/electrum.rs | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 3b51abd909..3ef971172c 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -1114,6 +1114,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; @@ -1124,6 +1125,9 @@ mod tests { use bitcoin::blockdata::constants::genesis_block; + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use super::*; use crate::runtime::Runtime; @@ -1255,6 +1259,49 @@ mod tests { ); } + #[test] + fn electrum_protocol_rejection_propagates_through_broadcast_worker() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let server_url = format!("tcp://{}", listener.local_addr().unwrap()); + let server_thread = thread::spawn(move || { + let (broadcast_stream, _) = listener.accept().unwrap(); + let (_sync_stream, _) = listener.accept().unwrap(); + let mut reader = BufReader::new(broadcast_stream.try_clone().unwrap()); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&request_line).unwrap(); + assert_eq!(request["method"], "blockchain.transaction.broadcast"); + + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -26, "message": "non-final" }, + }); + let mut response_stream = broadcast_stream; + writeln!(response_stream, "{}", response).unwrap(); + }); + + let logger = Arc::new(Logger::new_log_facade()); + let runtime = Arc::new(Runtime::new(Arc::clone(&logger)).unwrap()); + let client = ElectrumRuntimeClient::new( + server_url, + runtime.handle().clone(), + Arc::new(Config::default()), + logger, + 1, + ) + .unwrap(); + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![], + }; + + assert_eq!(runtime.block_on(client.broadcast(tx)), Err(TxBroadcastError::Rejected)); + server_thread.join().unwrap(); + } + #[test] fn inflight_electrum_worker_does_not_own_runtime_lifecycle() { if std::env::var_os(RUNTIME_SELF_DROP_CHILD_ENV).is_some() { From 055cae6b1874f1081d0739f68d2818f67ccf9b33 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 11:16:59 +0200 Subject: [PATCH 06/30] chore: refresh exact-head Swift artifact --- Package.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Package.swift b/Package.swift index 32d8fcd3f9..3cf534a9fa 100644 --- a/Package.swift +++ b/Package.swift @@ -13,11 +13,15 @@ let checksum = "3d4ed7123281235353cc7558317c3c15f94e0ff9adcab5fbb1fe35d572662f3d ======= let tag = "v0.7.0-rc.67" <<<<<<< HEAD +<<<<<<< HEAD let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" >>>>>>> 049b116 (fix: classify broadcast backend results) ======= let checksum = "ddbf716ae6d1bdaad8b10fa78abab7af4011956e4eb4d4b002f229dcb94f6c8c" >>>>>>> d7f3bfc (chore: refresh broadcast result artifacts) +======= +let checksum = "61565a56ecf33a1e8634de5e3be7d388f0579565161b1237c56756cc8b6638ce" +>>>>>>> e359f19 (chore: refresh exact-head Swift artifact) let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( From 606603d65caa74597bcbcc01ab4128ffceb9ed23 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 11:36:43 +0200 Subject: [PATCH 07/30] docs: place broadcast fixes in current changelog --- CHANGELOG.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be6773c79..a34f999717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ # 0.7.0-rc.67 (Synonym Fork) ## Bug Fixes +<<<<<<< HEAD - 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. +======= +- Explicit on-chain sends now return a transaction ID only after the configured backend accepts + the transaction, with distinct errors for rejection, failure, and timeout. +- Electrum transaction rejections are now logged as failures instead of successful broadcasts. +>>>>>>> 6967d92 (docs: place broadcast fixes in current changelog) - Add keep consumer rules for JNA types UniFFI needs under R8. # 0.7.0-rc.64 (Synonym Fork) @@ -83,9 +89,6 @@ ## Synonym Fork Additions -- Explicit on-chain sends now return a transaction ID only after the configured backend accepts - the transaction, with distinct errors for rejection, failure, and timeout. -- Electrum transaction rejections are now logged as failures instead of successful broadcasts. - 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. From bca2a153b304d1abdc90c1d9f8eaed32e32f0368 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 11:54:34 +0200 Subject: [PATCH 08/30] fix: make explicit broadcast retries safe --- CHANGELOG.md | 4 +- bindings/ldk_node.udl | 3 + crates/bdk-wallet-aggregate/src/lib.rs | 67 ++++++++ src/chain/mod.rs | 1 + src/error.rs | 15 +- src/payment/onchain.rs | 62 +++++-- src/tx_broadcaster.rs | 223 +++++++++++++++++++++---- src/wallet/mod.rs | 25 +++ 8 files changed, 354 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a34f999717..39ee9947da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ - 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. ======= - Explicit on-chain sends now return a transaction ID only after the configured backend accepts - the transaction, with distinct errors for rejection, failure, and timeout. + the transaction. Rejected, not-dispatched, acceptance-unknown failure, and acceptance-unknown + timeout outcomes are distinct; uncertain signed transactions persist for exact-tx reconciliation + and rebroadcast after restart. - Electrum transaction rejections are now logged as failures instead of successful broadcasts. >>>>>>> 6967d92 (docs: place broadcast fixes in current changelog) - Add keep consumer rules for JNA types UniFFI needs under R8. diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 33375e896f..ca2560d4c7 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -387,6 +387,8 @@ interface OnchainPayment { [Throws=NodeError] Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); [Throws=NodeError] + Txid rebroadcast_transaction([ByRef]Txid txid); + [Throws=NodeError] Txid bump_fee_by_rbf([ByRef]Txid txid, FeeRate fee_rate); [Throws=NodeError] Txid accelerate_by_cpfp([ByRef]Txid txid, FeeRate? fee_rate, Address? destination_address); @@ -507,6 +509,7 @@ enum NodeError { "OnchainTxBroadcastRejected", "OnchainTxBroadcastFailed", "OnchainTxBroadcastTimeout", + "OnchainTxBroadcastNotDispatched", }; dictionary NodeStatus { diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs index f30cb957b4..0f78dd089a 100644 --- a/crates/bdk-wallet-aggregate/src/lib.rs +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -625,6 +625,23 @@ 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, last_seen: u64) -> Result<(), Error> { + let txid = tx.compute_txid(); + for (key, wallet) in self.wallets.iter_mut() { + wallet.apply_evicted_txs([(txid, last_seen)]); + 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(()) + } + /// Cancel a dry-run transaction on the primary wallet without persisting. /// /// Unmarks change addresses that were marked "used" by `finish()`, so @@ -1861,6 +1878,56 @@ mod tests { .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, 1).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 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/chain/mod.rs b/src/chain/mod.rs index 32b3b01c66..db2f9cca1a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1300,6 +1300,7 @@ impl ChainSource { let tx_bcast_logger = Arc::clone(&self.logger); tokio::select! { _ = stop_tx_bcast_receiver.changed() => { + receivers.fail_queued_explicit_requests(); log_debug!( tx_bcast_logger, "Stopping broadcasting transactions.", diff --git a/src/error.rs b/src/error.rs index b8a9bd34f5..18ad3c2a0e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -158,10 +158,14 @@ pub enum Error { InvalidSeedBytes, /// The configured chain backend rejected an on-chain transaction. OnchainTxBroadcastRejected, - /// Broadcasting an on-chain transaction failed before backend acceptance was known. + /// Broadcasting failed after dispatch; backend acceptance is unknown and the transaction remains + /// persisted for reconciliation or exact rebroadcast. OnchainTxBroadcastFailed, - /// Broadcasting an on-chain transaction timed out before backend acceptance was known. + /// Broadcasting timed out after dispatch; backend acceptance is unknown and the transaction + /// remains persisted for reconciliation or exact rebroadcast. OnchainTxBroadcastTimeout, + /// The on-chain transaction was not handed to the configured backend. + OnchainTxBroadcastNotDispatched, } impl fmt::Display for Error { @@ -274,10 +278,13 @@ impl fmt::Display for Error { write!(f, "The on-chain transaction was rejected by the configured chain backend.") }, Self::OnchainTxBroadcastFailed => { - write!(f, "Failed to broadcast the on-chain transaction.") + write!(f, "On-chain broadcast acceptance is unknown after a backend failure.") }, Self::OnchainTxBroadcastTimeout => { - write!(f, "Broadcasting the on-chain transaction timed out.") + write!(f, "On-chain broadcast acceptance is unknown after a backend timeout.") + }, + Self::OnchainTxBroadcastNotDispatched => { + write!(f, "The on-chain transaction was not dispatched to the chain backend.") }, } } diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index acb2b40af6..eb1384c043 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -16,6 +16,7 @@ use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; use crate::logger::{log_info, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::TxBroadcastError; use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; @@ -116,6 +117,21 @@ impl OnchainPayment { Self { wallet, tx_broadcaster, runtime, channel_manager, config, is_running, logger } } + fn dispatch_prepared_transaction(&self, tx: bitcoin::Transaction) -> Result { + let txid = tx.compute_txid(); + self.wallet.persist_pending_broadcast(&tx)?; + match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx.clone())) { + Ok(()) => Ok(txid), + Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { + self.wallet.abandon_pending_broadcast(&tx)?; + Err(error.into()) + }, + Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { + Err(error.into()) + }, + } + } + /// Retrieve a new on-chain/funding address. pub fn new_address(&self) -> Result { let funding_address = self.wallet.get_new_address()?; @@ -542,9 +558,13 @@ 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 chain backend accepts the transaction. - /// A rejection, transport failure, or timeout is returned as an error. For transport failures - /// and timeouts, backend acceptance remains unknown and callers must not present success. + /// Returns the transaction ID only after the configured backend accepts the transaction. + /// The signed transaction is persisted before dispatch. If the backend result is unknown, the + /// pending on-chain payment remains available through [`crate::Node::list_payments`] 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 permits a fresh send. /// /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn send_to_address( @@ -568,9 +588,7 @@ impl OnchainPayment { outpoints, &self.channel_manager, )?; - let txid = tx.compute_txid(); - self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx))?; - Ok(txid) + self.dispatch_prepared_transaction(tx) } /// Send an on-chain payment to the given address, draining the available funds. @@ -589,9 +607,13 @@ 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 chain backend accepts the transaction. - /// A rejection, transport failure, or timeout is returned as an error. For transport failures - /// and timeouts, backend acceptance remains unknown and callers must not present success. + /// Returns the transaction ID only after the configured backend accepts the transaction. + /// The signed transaction is persisted before dispatch. If the backend result is unknown, the + /// pending on-chain payment remains available through [`crate::Node::list_payments`] 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 permits a fresh send. /// /// [`calculate_send_all_fee`]: Self::calculate_send_all_fee /// [`BalanceDetails::spendable_onchain_balance_sats`]: crate::balance::BalanceDetails::spendable_onchain_balance_sats @@ -618,9 +640,25 @@ impl OnchainPayment { None, &self.channel_manager, )?; - let txid = tx.compute_txid(); - self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx))?; - Ok(txid) + self.dispatch_prepared_transaction(tx) + } + + /// Rebroadcast a previously prepared on-chain transaction without creating a new spend. + /// + /// Use this after recovering an acceptance-unknown transaction ID from + /// [`crate::Node::list_payments`]. + /// The exact persisted transaction is reused, so retrying cannot create a second payment + /// transaction. On success, the same transaction ID is returned. + /// + /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if the + /// transaction is not available in the wallet's persistent transaction graph. + pub fn rebroadcast_transaction(&self, txid: &Txid) -> Result { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let tx = self.wallet.find_transaction(txid).ok_or(Error::TransactionNotFound)?; + self.dispatch_prepared_transaction(tx) } /// Bumps the fee of an existing transaction using Replace-By-Fee (RBF). diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 649450fd82..17960389b3 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,6 +6,8 @@ // 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, Txid}; @@ -21,6 +23,7 @@ const EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE: usize = 50; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TxBroadcastError { Rejected, + NotDispatched, Failed, Timeout, } @@ -91,15 +94,87 @@ impl From for Error { fn from(error: TxBroadcastError) -> Self { match error { TxBroadcastError::Rejected => Error::OnchainTxBroadcastRejected, + TxBroadcastError::NotDispatched => Error::OnchainTxBroadcastNotDispatched, TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed, TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout, } } } +const EXPLICIT_BROADCAST_QUEUED: u8 = 0; +const EXPLICIT_BROADCAST_CLAIMED: u8 = 1; +const EXPLICIT_BROADCAST_CANCELLED: u8 = 2; + +struct ExplicitBroadcastClaim { + state: AtomicU8, +} + +impl ExplicitBroadcastClaim { + fn new() -> Self { + Self { state: AtomicU8::new(EXPLICIT_BROADCAST_QUEUED) } + } + + fn try_claim(&self) -> bool { + self.state + .compare_exchange( + EXPLICIT_BROADCAST_QUEUED, + EXPLICIT_BROADCAST_CLAIMED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn cancel_if_queued(&self) -> bool { + self.state + .compare_exchange( + EXPLICIT_BROADCAST_QUEUED, + EXPLICIT_BROADCAST_CANCELLED, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } +} + +struct CancelExplicitBroadcastOnDrop { + claim: Arc, +} + +impl Drop for CancelExplicitBroadcastOnDrop { + fn drop(&mut self) { + self.claim.cancel_if_queued(); + } +} + pub(crate) struct BroadcastRequest { pub(crate) package: Vec, pub(crate) result_sender: Option>>, + explicit_claim: Option>, +} + +impl BroadcastRequest { + fn explicit( + package: Vec, result_sender: oneshot::Sender>, + ) -> (Self, Arc) { + let explicit_claim = Arc::new(ExplicitBroadcastClaim::new()); + ( + Self { + package, + result_sender: Some(result_sender), + explicit_claim: Some(Arc::clone(&explicit_claim)), + }, + explicit_claim, + ) + } + + fn ldk(package: Vec) -> Self { + Self { package, result_sender: None, explicit_claim: None } + } + + fn try_claim(&self) -> bool { + self.explicit_claim.as_ref().map_or(true, |claim| claim.try_claim()) + } } /// Separate receivers for safety-critical LDK traffic and bounded explicit user sends. @@ -111,10 +186,29 @@ pub(crate) struct BroadcastQueueReceivers { impl BroadcastQueueReceivers { /// Returns the next request, prioritizing LDK traffic when both queues are ready. pub(crate) async fn recv(&mut self) -> Option { - tokio::select! { - biased; - request = self.ldk_receiver.recv() => request, - request = self.explicit_receiver.recv() => request, + loop { + let request = tokio::select! { + biased; + request = self.ldk_receiver.recv() => request, + request = self.explicit_receiver.recv() => request, + }; + match request { + Some(request) if request.try_claim() => return Some(request), + Some(_) => continue, + None => return None, + } + } + } + + /// Completes queued explicit requests without dispatching them when the worker stops. + pub(crate) fn fail_queued_explicit_requests(&mut self) { + while let Ok(request) = self.explicit_receiver.try_recv() { + if !request.try_claim() { + continue; + } + if let Some(result_sender) = request.result_sender { + let _ = result_sender.send(Err(TxBroadcastError::NotDispatched)); + } } } } @@ -147,22 +241,29 @@ where self.queue_receivers.lock().await } - pub(crate) async fn broadcast_transaction(&self, tx: Transaction) -> Result<(), Error> { + pub(crate) async fn broadcast_transaction( + &self, tx: Transaction, + ) -> Result<(), TxBroadcastError> { self.broadcast_transaction_with_timeout(tx, Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS)) .await } async fn broadcast_transaction_with_timeout( &self, tx: Transaction, timeout: Duration, - ) -> Result<(), Error> { + ) -> Result<(), TxBroadcastError> { let (result_sender, result_receiver) = oneshot::channel(); - let request = BroadcastRequest { package: vec![tx], result_sender: Some(result_sender) }; - self.explicit_sender.try_send(request).map_err(|_| TxBroadcastError::Failed)?; - let result = tokio::time::timeout(timeout, result_receiver) - .await - .map_err(|_| TxBroadcastError::Timeout)? - .map_err(|_| TxBroadcastError::Failed)?; - result.map_err(Into::into) + let (request, explicit_claim) = BroadcastRequest::explicit(vec![tx], result_sender); + 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)? } } @@ -172,7 +273,7 @@ where { fn broadcast_transactions(&self, txs: &[&Transaction]) { let package = txs.iter().map(|&t| t.clone()).collect::>(); - let request = BroadcastRequest { package, result_sender: None }; + let request = BroadcastRequest::ldk(package); self.ldk_sender.send(request).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); @@ -194,12 +295,15 @@ mod tests { classify_rpc_broadcast_error, BroadcastRequest, TransactionBroadcaster, TxBroadcastError, EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE, }; - use crate::Error; 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::ZERO, + lock_time: LockTime::from_consensus(lock_time), input: vec![], output: vec![], } @@ -250,7 +354,7 @@ mod tests { }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); - assert_eq!(result, Err(Error::OnchainTxBroadcastRejected)); + assert_eq!(result, Err(TxBroadcastError::Rejected)); } #[tokio::test] @@ -264,11 +368,11 @@ mod tests { }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); - assert_eq!(result, Err(Error::OnchainTxBroadcastFailed)); + assert_eq!(result, Err(TxBroadcastError::Failed)); } #[tokio::test] - async fn explicit_broadcast_times_out_without_backend_result() { + async fn claimed_explicit_broadcast_waits_for_backend_result_after_queue_timeout() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let broadcast_fut = broadcaster .broadcast_transaction_with_timeout(test_transaction(), Duration::from_millis(10)); @@ -276,11 +380,76 @@ mod tests { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); tokio::time::sleep(Duration::from_millis(20)).await; - drop(request); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn queued_explicit_broadcast_is_cancelled_before_backend_claim() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let cancelled_result = broadcaster + .broadcast_transaction_with_timeout(test_transaction(), Duration::from_millis(10)) + .await; + assert_eq!(cancelled_result, Err(TxBroadcastError::NotDispatched)); + + let live_tx = test_transaction_with_lock_time(1); + let broadcast_fut = + broadcaster.broadcast_transaction_with_timeout(live_tx.clone(), Duration::from_secs(1)); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![live_tx]); + request.result_sender.unwrap().send(Ok(())).unwrap(); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); - assert_eq!(result, Err(Error::OnchainTxBroadcastTimeout)); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn dropped_explicit_broadcast_future_cancels_queued_request() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let cancelled_broadcaster = Arc::clone(&broadcaster); + let cancelled_task = tokio::spawn(async move { + cancelled_broadcaster + .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)) + .await + }); + tokio::task::yield_now().await; + assert_eq!(broadcaster.explicit_sender.capacity(), EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE - 1); + cancelled_task.abort(); + assert!(cancelled_task.await.unwrap_err().is_cancelled()); + + let live_tx = test_transaction_with_lock_time(1); + let broadcast_fut = + broadcaster.broadcast_transaction_with_timeout(live_tx.clone(), Duration::from_secs(1)); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![live_tx]); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + + #[tokio::test] + async fn stopping_worker_fails_queued_explicit_broadcast_without_dispatch() { + let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); + let broadcast_fut = broadcaster + .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)); + let stop_fut = async { + tokio::task::yield_now().await; + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + receivers.fail_queued_explicit_requests(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, stop_fut); + assert_eq!(result, Err(TxBroadcastError::NotDispatched)); } #[tokio::test] @@ -300,20 +469,16 @@ mod tests { 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(); - broadcaster - .explicit_sender - .try_send(BroadcastRequest { - package: vec![test_transaction()], - result_sender: Some(result_sender), - }) - .unwrap(); + let (request, _claim) = + BroadcastRequest::explicit(vec![test_transaction()], result_sender); + broadcaster.explicit_sender.try_send(request).unwrap(); } assert_eq!( broadcaster .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)) .await, - Err(Error::OnchainTxBroadcastFailed) + Err(TxBroadcastError::NotDispatched) ); let ldk_tx = test_transaction(); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index edad41bc9c..2a53629a76 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; @@ -822,6 +823,30 @@ impl Wallet { self.update_payment_store(&locked_wallet) } + /// Persist an explicit signed transaction before it can cross the backend trust boundary. + pub(crate) fn persist_pending_broadcast(&self, tx: &Transaction) -> Result<(), Error> { + let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + self.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) + } + + /// Release a transaction that was rejected or was provably never dispatched. + pub(crate) fn abandon_pending_broadcast(&self, tx: &Transaction) -> Result<(), Error> { + let txid = tx.compute_txid(); + let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let mut locked_wallet = self.inner.lock().unwrap(); + locked_wallet.abandon_tx(tx, last_seen).map_err(|e| { + log_error!(self.logger, "Failed to abandon pending transaction {}: {}", txid, e); + Error::PersistenceFailed + })?; + drop(locked_wallet); + self.payment_store.remove(&PaymentId(txid.to_byte_array())) + } + + /// Recover the exact persisted transaction bytes for reconciliation or rebroadcast. + pub(crate) fn find_transaction(&self, txid: &Txid) -> Option { + self.inner.lock().unwrap().find_tx(*txid) + } + // 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( From c89d696b2a825857ffc69abe0eba0e1e9ea4f810 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 12:16:48 +0200 Subject: [PATCH 09/30] fix: persist unresolved broadcast intents --- CHANGELOG.md | 7 +- bindings/ldk_node.udl | 148 ++++++++++----------- src/builder.rs | 4 + src/chain/mod.rs | 1 + src/error.rs | 43 +++++-- src/io/mod.rs | 4 + src/lib.rs | 122 +++++++++++++++++- src/payment/onchain.rs | 200 ++++++++++++++++++++++++++--- src/tx_broadcaster.rs | 73 +++++++++-- src/wallet/mod.rs | 283 +++++++++++++++++++++++++++++++++++++++-- 10 files changed, 745 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ee9947da..2bb618e9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ - 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. ======= - Explicit on-chain sends now return a transaction ID only after the configured backend accepts - the transaction. Rejected, not-dispatched, acceptance-unknown failure, and acceptance-unknown - timeout outcomes are distinct; uncertain signed transactions persist for exact-tx reconciliation - and rebroadcast after restart. + 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. - Electrum transaction rejections are now logged as failures instead of successful broadcasts. >>>>>>> 6967d92 (docs: place broadcast fixes in current changelog) - Add keep consumer rules for JNA types UniFFI needs under R8. diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index ca2560d4c7..cd5fb53ddf 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -388,6 +388,8 @@ interface OnchainPayment { Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); [Throws=NodeError] Txid rebroadcast_transaction([ByRef]Txid txid); + [Throws=NodeError] + sequence list_pending_broadcasts(); [Throws=NodeError] Txid bump_fee_by_rbf([ByRef]Txid txid, FeeRate fee_rate); [Throws=NodeError] @@ -437,79 +439,79 @@ interface LSPS1Liquidity { }; [Error] -enum NodeError { - "AlreadyRunning", - "NotRunning", - "OnchainTxCreationFailed", - "ConnectionFailed", - "InvoiceCreationFailed", - "InvoiceRequestCreationFailed", - "OfferCreationFailed", - "RefundCreationFailed", - "PaymentSendingFailed", - "InvalidCustomTlvs", - "ProbeSendingFailed", - "RouteNotFound", - "ChannelCreationFailed", - "ChannelClosingFailed", - "ChannelSplicingFailed", - "ChannelConfigUpdateFailed", - "PersistenceFailed", - "FeerateEstimationUpdateFailed", - "FeerateEstimationUpdateTimeout", - "WalletOperationFailed", - "WalletOperationTimeout", - "OnchainTxSigningFailed", - "TxSyncFailed", - "TxSyncTimeout", - "GossipUpdateFailed", - "GossipUpdateTimeout", - "LiquidityRequestFailed", - "UriParameterParsingFailed", - "InvalidAddress", - "InvalidSocketAddress", - "InvalidPublicKey", - "InvalidSecretKey", - "InvalidOfferId", - "InvalidNodeId", - "InvalidPaymentId", - "InvalidPaymentHash", - "InvalidPaymentPreimage", - "InvalidPaymentSecret", - "InvalidAmount", - "InvalidInvoice", - "InvalidOffer", - "InvalidRefund", - "InvalidChannelId", - "InvalidNetwork", - "InvalidUri", - "InvalidQuantity", - "InvalidNodeAlias", - "InvalidDateTime", - "InvalidFeeRate", - "DuplicatePayment", - "UnsupportedCurrency", - "InsufficientFunds", - "LiquiditySourceUnavailable", - "LiquidityFeeTooHigh", - "InvalidBlindedPaths", - "AsyncPaymentServicesDisabled", - "CannotRbfFundingTransaction", - "TransactionNotFound", - "TransactionAlreadyConfirmed", - "NoSpendableOutputs", - "CoinSelectionFailed", - "InvalidMnemonic", - "BackgroundSyncNotEnabled", - "AddressTypeAlreadyMonitored", - "AddressTypeIsPrimary", - "AddressTypeNotMonitored", - "OnchainWalletAccountNotRegistered", - "InvalidSeedBytes", - "OnchainTxBroadcastRejected", - "OnchainTxBroadcastFailed", - "OnchainTxBroadcastTimeout", - "OnchainTxBroadcastNotDispatched", +interface NodeError { + AlreadyRunning(); + NotRunning(); + OnchainTxCreationFailed(); + ConnectionFailed(); + InvoiceCreationFailed(); + InvoiceRequestCreationFailed(); + OfferCreationFailed(); + RefundCreationFailed(); + PaymentSendingFailed(); + InvalidCustomTlvs(); + ProbeSendingFailed(); + RouteNotFound(); + ChannelCreationFailed(); + ChannelClosingFailed(); + ChannelSplicingFailed(); + ChannelConfigUpdateFailed(); + PersistenceFailed(); + FeerateEstimationUpdateFailed(); + FeerateEstimationUpdateTimeout(); + WalletOperationFailed(); + WalletOperationTimeout(); + OnchainTxSigningFailed(); + TxSyncFailed(); + TxSyncTimeout(); + GossipUpdateFailed(); + GossipUpdateTimeout(); + LiquidityRequestFailed(); + UriParameterParsingFailed(); + InvalidAddress(); + InvalidSocketAddress(); + InvalidPublicKey(); + InvalidSecretKey(); + InvalidOfferId(); + InvalidNodeId(); + InvalidPaymentId(); + InvalidPaymentHash(); + InvalidPaymentPreimage(); + InvalidPaymentSecret(); + InvalidAmount(); + InvalidInvoice(); + InvalidOffer(); + InvalidRefund(); + InvalidChannelId(); + InvalidNetwork(); + InvalidUri(); + InvalidQuantity(); + InvalidNodeAlias(); + InvalidDateTime(); + InvalidFeeRate(); + DuplicatePayment(); + UnsupportedCurrency(); + InsufficientFunds(); + LiquiditySourceUnavailable(); + LiquidityFeeTooHigh(); + InvalidBlindedPaths(); + AsyncPaymentServicesDisabled(); + CannotRbfFundingTransaction(); + TransactionNotFound(); + TransactionAlreadyConfirmed(); + NoSpendableOutputs(); + CoinSelectionFailed(); + InvalidMnemonic(); + BackgroundSyncNotEnabled(); + AddressTypeAlreadyMonitored(); + AddressTypeIsPrimary(); + AddressTypeNotMonitored(); + OnchainWalletAccountNotRegistered(); + InvalidSeedBytes(); + OnchainTxBroadcastRejected(Txid txid); + OnchainTxBroadcastFailed(Txid txid); + OnchainTxBroadcastTimeout(Txid txid); + OnchainTxBroadcastNotDispatched(Txid txid); }; dictionary NodeStatus { diff --git a/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/mod.rs b/src/chain/mod.rs index db2f9cca1a..8c719aa16a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1300,6 +1300,7 @@ impl ChainSource { let tx_bcast_logger = Arc::clone(&self.logger); tokio::select! { _ = stop_tx_bcast_receiver.changed() => { + self.tx_broadcaster.pause_explicit_broadcasts(); receivers.fail_queued_explicit_requests(); log_debug!( tx_bcast_logger, diff --git a/src/error.rs b/src/error.rs index 18ad3c2a0e..42f2991213 100644 --- a/src/error.rs +++ b/src/error.rs @@ -157,15 +157,27 @@ pub enum Error { /// The seed bytes or a seed-derived wallet account are invalid. InvalidSeedBytes, /// The configured chain backend rejected an on-chain transaction. - OnchainTxBroadcastRejected, - /// Broadcasting failed after dispatch; backend acceptance is unknown and the transaction remains - /// persisted for reconciliation or exact rebroadcast. - OnchainTxBroadcastFailed, + 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, + OnchainTxBroadcastTimeout { + /// The transaction ID requiring reconciliation or exact rebroadcast. + txid: bitcoin::Txid, + }, /// The on-chain transaction was not handed to the configured backend. - OnchainTxBroadcastNotDispatched, + OnchainTxBroadcastNotDispatched { + /// The transaction ID that was not dispatched. + txid: bitcoin::Txid, + }, } impl fmt::Display for Error { @@ -274,17 +286,20 @@ impl fmt::Display for Error { Self::InvalidSeedBytes => { write!(f, "The seed bytes or seed-derived wallet account are invalid.") }, - Self::OnchainTxBroadcastRejected => { - write!(f, "The on-chain transaction was rejected by the configured chain backend.") + Self::OnchainTxBroadcastRejected { txid } => { + write!( + f, + "On-chain transaction {txid} was rejected by the configured chain backend." + ) }, - Self::OnchainTxBroadcastFailed => { - write!(f, "On-chain broadcast acceptance is unknown after a backend failure.") + Self::OnchainTxBroadcastFailed { txid } => { + write!(f, "On-chain transaction {txid} requires reconciliation after a broadcast or persistence failure.") }, - Self::OnchainTxBroadcastTimeout => { - write!(f, "On-chain broadcast acceptance is unknown after a backend timeout.") + Self::OnchainTxBroadcastTimeout { txid } => { + write!(f, "On-chain transaction {txid} has unknown broadcast acceptance after a backend timeout.") }, - Self::OnchainTxBroadcastNotDispatched => { - write!(f, "The on-chain transaction was not dispatched to the chain backend.") + Self::OnchainTxBroadcastNotDispatched { txid } => { + write!(f, "On-chain transaction {txid} was not dispatched to the chain backend.") }, } } diff --git a/src/io/mod.rs b/src/io/mod.rs index 611acc8362..6010fe6621 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -28,6 +28,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers"; pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// Explicit on-chain broadcast intents are persisted under this prefix. +pub(crate) const ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE: &str = "onchain_broadcast_intents"; +pub(crate) const ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/lib.rs b/src/lib.rs index 46bdfe81b7..fa9cc2fa7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -356,6 +356,7 @@ impl Node { // Set event queue for onchain event emission self.chain_source.set_event_queue(Arc::clone(&self.event_queue)); + self.tx_broadcaster.resume_explicit_broadcasts(); self.spawn_chain_sync_task(); @@ -827,6 +828,7 @@ impl Node { self.background_processor_generation.fetch_add(1, Ordering::AcqRel); log_info!(self.logger, "Shutting down LDK Node with node ID {}...", self.node_id()); + self.tx_broadcaster.pause_explicit_broadcasts(); // Prevent all task groups from accepting work that could outlive this shutdown generation. self.runtime.close_task_admission(); @@ -851,6 +853,7 @@ impl Node { // Cancel cancellable background tasks self.runtime.abort_cancellable_background_tasks(); + self.runtime.block_on(self.tx_broadcaster.drain_explicit_broadcasts()); // Disconnect all peers. self.peer_manager.disconnect_all_peers(); @@ -2479,7 +2482,9 @@ mod tests { use std::sync::Arc; use std::time::Duration; - use bitcoin::Network; + use bitcoin::absolute::LockTime; + use bitcoin::transaction::Version; + use bitcoin::{Network, Transaction}; use lightning::io; use lightning::util::persist::{KVStore, KVStoreSync}; @@ -2580,6 +2585,121 @@ 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_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + node.wallet.update_payment_store_for_all_transactions().unwrap(); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn exact_rebroadcast_retains_unknown_retry_and_clears_on_acceptance() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_broadcast_transaction(46); + let txid = tx.compute_txid(); + let node = test_wallet_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + *node.is_running.write().unwrap() = true; + + let payment = node.onchain_payment(); + let first_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let first_request = receivers.recv().await.unwrap(); + assert_eq!(first_request.package, vec![tx.clone()]); + first_request + .result_sender + .unwrap() + .send(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)) + .unwrap(); + drop(receivers); + assert_eq!(first_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + + let payment = node.onchain_payment(); + let second_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let second_request = receivers.recv().await.unwrap(); + assert_eq!(second_request.package, vec![tx]); + second_request.result_sender.unwrap().send(Ok(())).unwrap(); + drop(receivers); + assert_eq!(second_call.await.unwrap(), Ok(txid)); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + *node.is_running.write().unwrap() = false; + } + #[test] fn background_processor_failure_marks_node_unhealthy() { let config = Config { network: Network::Regtest, ..Config::default() }; diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index eb1384c043..28efffb8e4 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -14,7 +14,7 @@ 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::logger::{log_error, log_info, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::tx_broadcaster::TxBroadcastError; use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; @@ -119,19 +119,49 @@ impl OnchainPayment { fn dispatch_prepared_transaction(&self, tx: bitcoin::Transaction) -> Result { let txid = tx.compute_txid(); - self.wallet.persist_pending_broadcast(&tx)?; + self.wallet.prepare_pending_broadcast(&tx)?; match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx.clone())) { - Ok(()) => Ok(txid), + Ok(()) => { + if let Err(e) = self.wallet.clear_broadcast_intent(&txid) { + log_error!( + self.logger, + "Failed to clear accepted broadcast intent {}: {}", + txid, + e + ); + } + Ok(txid) + }, Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { - self.wallet.abandon_pending_broadcast(&tx)?; - Err(error.into()) + 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)) => { - Err(error.into()) + Err(Self::initial_broadcast_error(error, txid)) }, } } + fn initial_broadcast_error(error: TxBroadcastError, txid: Txid) -> Error { + match error { + TxBroadcastError::Rejected => Error::OnchainTxBroadcastRejected { txid }, + TxBroadcastError::NotDispatched => Error::OnchainTxBroadcastNotDispatched { txid }, + TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed { txid }, + TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout { txid }, + } + } + + fn rebroadcast_error(error: TxBroadcastError, txid: Txid) -> Error { + match error { + TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout { txid }, + TxBroadcastError::Rejected + | TxBroadcastError::NotDispatched + | TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed { txid }, + } + } + /// Retrieve a new on-chain/funding address. pub fn new_address(&self) -> Result { let funding_address = self.wallet.get_new_address()?; @@ -559,12 +589,13 @@ impl OnchainPayment { /// 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. If the backend result is unknown, the - /// pending on-chain payment remains available through [`crate::Node::list_payments`] 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 + /// 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 permits a fresh send. + /// 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( @@ -608,12 +639,13 @@ impl OnchainPayment { /// 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. If the backend result is unknown, the - /// pending on-chain payment remains available through [`crate::Node::list_payments`] 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 + /// 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 permits a fresh send. + /// 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 @@ -646,19 +678,43 @@ impl OnchainPayment { /// Rebroadcast a previously prepared on-chain transaction without creating a new spend. /// /// Use this after recovering an acceptance-unknown transaction ID from - /// [`crate::Node::list_payments`]. + /// [`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. + /// 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. /// /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if the - /// transaction is not available in the wallet's persistent transaction graph. + /// 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 tx = self.wallet.find_transaction(txid).ok_or(Error::TransactionNotFound)?; - self.dispatch_prepared_transaction(tx) + let tx = self.wallet.recover_pending_broadcast(txid)?.ok_or(Error::TransactionNotFound)?; + match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx)) { + Ok(()) => { + if let Err(e) = self.wallet.clear_broadcast_intent(txid) { + log_error!( + self.logger, + "Failed to clear accepted rebroadcast intent {}: {}", + txid, + e + ); + } + Ok(*txid) + }, + Err(error) => Err(Self::rebroadcast_error(error, *txid)), + } + } + + /// List explicit transaction IDs whose backend acceptance is still unresolved. + /// + /// Entries survive process restart and ordinary mempool eviction. A transaction is removed only + /// after a conclusive initial rejection/not-dispatched result, successful explicit broadcast, or + /// observation by the configured backend. + pub fn list_pending_broadcasts(&self) -> Result, Error> { + self.wallet.list_pending_broadcasts() } /// Bumps the fee of an existing transaction using Replace-By-Fee (RBF). @@ -787,3 +843,105 @@ impl OnchainPayment { } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use bitcoin::absolute::LockTime; + use bitcoin::hashes::Hash; + use bitcoin::transaction::Version; + use bitcoin::{Network, Transaction, Txid}; + + use super::OnchainPayment; + use crate::builder::NodeBuilder; + use crate::config::Config; + use crate::error::Error; + use crate::io::test_utils::InMemoryStore; + use crate::tx_broadcaster::TxBroadcastError; + use crate::types::DynStore; + use crate::Node; + + fn test_node(store: Arc) -> Node { + let config = Config { network: Network::Regtest, ..Config::default() }; + let mut builder = NodeBuilder::from_config(config); + builder.set_chain_source_esplora("http://127.0.0.1:1".to_string(), None); + builder.set_entropy_seed_bytes([43u8; 64]); + builder.set_log_facade_logger(); + builder.build_with_store(store).unwrap() + } + + fn test_transaction() -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(47), + input: vec![], + output: vec![], + } + } + + #[test] + fn rebroadcast_errors_preserve_the_original_unknown_outcome() { + let txid = Txid::all_zeros(); + for error in + [TxBroadcastError::Rejected, TxBroadcastError::NotDispatched, TxBroadcastError::Failed] + { + assert_eq!( + OnchainPayment::rebroadcast_error(error, txid), + Error::OnchainTxBroadcastFailed { txid } + ); + } + assert_eq!( + OnchainPayment::rebroadcast_error(TxBroadcastError::Timeout, txid), + Error::OnchainTxBroadcastTimeout { txid } + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn failed_send_survives_eviction_restart_and_retry_until_accepted() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let node = test_node(Arc::clone(&store)); + let payment = node.onchain_payment(); + let send_tx = tx.clone(); + let initial_call = + tokio::task::spawn_blocking(move || payment.dispatch_prepared_transaction(send_tx)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let initial_request = receivers.recv().await.unwrap(); + assert_eq!(initial_request.package, vec![tx.clone()]); + initial_request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + drop(receivers); + assert_eq!(initial_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + node.wallet.apply_mempool_txs(Vec::new(), vec![(txid, 1)]).unwrap(); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + drop(node); + + let restarted_node = test_node(store); + assert_eq!(restarted_node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + *restarted_node.is_running.write().unwrap() = true; + + let payment = restarted_node.onchain_payment(); + let retry_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = restarted_node.tx_broadcaster.get_broadcast_queue_receivers().await; + let retry_request = receivers.recv().await.unwrap(); + assert_eq!(retry_request.package, vec![tx.clone()]); + retry_request.result_sender.unwrap().send(Err(TxBroadcastError::NotDispatched)).unwrap(); + drop(receivers); + assert_eq!(retry_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(restarted_node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + + let payment = restarted_node.onchain_payment(); + let accepted_call = + tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = restarted_node.tx_broadcaster.get_broadcast_queue_receivers().await; + let accepted_request = receivers.recv().await.unwrap(); + assert_eq!(accepted_request.package, vec![tx]); + accepted_request.result_sender.unwrap().send(Ok(())).unwrap(); + drop(receivers); + assert_eq!(accepted_call.await.unwrap(), Ok(txid)); + assert!(restarted_node.wallet.list_pending_broadcasts().unwrap().is_empty()); + *restarted_node.is_running.write().unwrap() = false; + } +} diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 17960389b3..bc110321c5 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -15,7 +15,6 @@ use lightning::chain::chaininterface::BroadcasterInterface; use tokio::sync::{mpsc, oneshot, Mutex, MutexGuard}; use crate::config::TX_BROADCAST_TIMEOUT_SECS; -use crate::error::Error; use crate::logger::{log_error, LdkLogger}; const EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE: usize = 50; @@ -90,17 +89,6 @@ pub(crate) fn validate_broadcast_txid( } } -impl From for Error { - fn from(error: TxBroadcastError) -> Self { - match error { - TxBroadcastError::Rejected => Error::OnchainTxBroadcastRejected, - TxBroadcastError::NotDispatched => Error::OnchainTxBroadcastNotDispatched, - TxBroadcastError::Failed => Error::OnchainTxBroadcastFailed, - TxBroadcastError::Timeout => Error::OnchainTxBroadcastTimeout, - } - } -} - const EXPLICIT_BROADCAST_QUEUED: u8 = 0; const EXPLICIT_BROADCAST_CLAIMED: u8 = 1; const EXPLICIT_BROADCAST_CANCELLED: u8 = 2; @@ -220,6 +208,7 @@ where ldk_sender: mpsc::UnboundedSender, explicit_sender: mpsc::Sender, queue_receivers: Mutex, + explicit_broadcasts_enabled: std::sync::Mutex, logger: L, } @@ -232,7 +221,29 @@ where let (explicit_sender, explicit_receiver) = mpsc::channel(EXPLICIT_BCAST_PACKAGE_QUEUE_SIZE); let queue_receivers = Mutex::new(BroadcastQueueReceivers { ldk_receiver, explicit_receiver }); - Self { ldk_sender, explicit_sender, queue_receivers, logger } + Self { + ldk_sender, + explicit_sender, + queue_receivers, + explicit_broadcasts_enabled: std::sync::Mutex::new(true), + logger, + } + } + + /// Allows explicit broadcasts for a new node run after the prior queue was drained. + pub(crate) fn resume_explicit_broadcasts(&self) { + *self.explicit_broadcasts_enabled.lock().unwrap() = true; + } + + /// Prevents new explicit broadcasts from entering the current run's queue. + pub(crate) fn pause_explicit_broadcasts(&self) { + *self.explicit_broadcasts_enabled.lock().unwrap() = false; + } + + /// 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( @@ -253,7 +264,13 @@ where ) -> Result<(), TxBroadcastError> { let (result_sender, result_receiver) = oneshot::channel(); let (request, explicit_claim) = BroadcastRequest::explicit(vec![tx], result_sender); - self.explicit_sender.try_send(request).map_err(|_| TxBroadcastError::NotDispatched)?; + { + let enabled = self.explicit_broadcasts_enabled.lock().unwrap(); + if !*enabled { + 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 { @@ -452,6 +469,34 @@ mod tests { 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()))); + broadcaster.pause_explicit_broadcasts(); + broadcaster.drain_explicit_broadcasts().await; + + assert_eq!( + broadcaster + .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)) + .await, + Err(TxBroadcastError::NotDispatched) + ); + + broadcaster.resume_explicit_broadcasts(); + let live_tx = test_transaction_with_lock_time(1); + let broadcast_fut = + broadcaster.broadcast_transaction_with_timeout(live_tx.clone(), Duration::from_secs(1)); + let process_fut = async { + let mut receivers = broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + assert_eq!(request.package, vec![live_tx]); + request.result_sender.unwrap().send(Ok(())).unwrap(); + }; + + let (result, ()) = tokio::join!(broadcast_fut, process_fut); + assert_eq!(result, Ok(())); + } + #[tokio::test] async fn ldk_broadcast_remains_fire_and_forget() { let broadcaster = TransactionBroadcaster::new(Arc::new(TestLogger::new())); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 2a53629a76..8753c645d1 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -25,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}; @@ -49,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; @@ -58,6 +60,9 @@ use crate::config::{ }; use crate::event::{TxInput, TxOutput}; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; +use crate::io::{ + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, +}; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::payment::store::ConfirmationStatus; use crate::payment::{KeychainKind, PaymentDetails, PaymentDirection, PaymentStatus}; @@ -68,6 +73,7 @@ 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 ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 1; fn validate_derivation_index(index: u32) -> Result<(), Error> { if index > BIP32_MAX_NORMAL_INDEX { @@ -89,6 +95,17 @@ 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 + .txs + .iter() + .map(|tx| tx.compute_txid()) + .chain(update.tx_update.anchors.iter().map(|(_, txid)| *txid)) + .chain(update.tx_update.seen_ats.iter().map(|(txid, _)| *txid)) + .collect() +} + fn map_wallet_account_error( wallet_account: OnchainWalletAccount, error: bdk_wallet_aggregate::Error, ) -> Error { @@ -136,6 +153,9 @@ pub(crate) mod ser; pub(crate) struct Wallet { inner: Mutex>, + // Serializes raw-intent persistence with wallet reservation and backend reconciliation. + broadcast_intent_lock: Mutex<()>, + backend_observed_broadcasts: Mutex>, // Serializes account membership, primary selection, and account reloads. operation_lock: Mutex<()>, account_generation: AtomicU64, @@ -176,6 +196,8 @@ impl Wallet { let operation_lock = Mutex::new(()); Self { inner, + broadcast_intent_lock: Mutex::new(()), + backend_observed_broadcasts: Mutex::new(HashSet::new()), operation_lock, account_generation: AtomicU64::new(0), synced_derived_accounts: Mutex::new(HashSet::new()), @@ -348,6 +370,8 @@ 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(); + let observed_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); 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| { @@ -364,10 +388,13 @@ impl Wallet { _ => Error::WalletOperationFailed, } })?; + drop(locked); + self.backend_observed_broadcasts.lock().unwrap().extend(observed_txids); Ok(()) } pub(crate) fn finish_pending_sync(&self, refresh_payment_store: bool) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); let mut locked = self.inner.lock().unwrap(); locked.persist_all().map_err(|e| { log_error!(self.logger, "Failed to persist pending wallet changes: {}", e); @@ -383,7 +410,8 @@ impl Wallet { e })?; } - Ok(()) + drop(locked); + self.flush_observed_broadcast_intents() } // Get a drain script for change outputs. @@ -769,9 +797,22 @@ impl Wallet { pub(crate) fn apply_update( &self, update: impl Into, ) -> Result, Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + let update = update.into(); + let observed_txids = backend_observed_txids(&update); + let unresolved_txs = self + .read_all_broadcast_intents()? + .into_iter() + .filter(|tx| !observed_txids.contains(&tx.compute_txid())) + .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.backend_observed_broadcasts.lock().unwrap().extend(observed_txids); + Ok(events) + }, Err(e) => { log_error!(self.logger, "Sync failed due to chain connection error: {}", e); Err(match e { @@ -789,12 +830,25 @@ 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(); + let update = update.into(); + let observed_txids = backend_observed_txids(&update); + let unresolved_txs = self + .read_all_broadcast_intents()? + .into_iter() + .filter(|tx| !observed_txids.contains(&tx.compute_txid())) + .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.backend_observed_broadcasts.lock().unwrap().extend(observed_txids); + Ok(Some(events)) + }, Err(e) => { log_error!( self.logger, @@ -814,23 +868,48 @@ 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(); + let observed_txids = + unconfirmed_txs.iter().map(|(tx, _)| tx.compute_txid()).collect::>(); + let unresolved_txs = self + .read_all_broadcast_intents()? + .into_iter() + .filter(|tx| !observed_txids.contains(&tx.compute_txid())) + .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.remove_broadcast_intents(observed_txids) } - /// Persist an explicit signed transaction before it can cross the backend trust boundary. - pub(crate) fn persist_pending_broadcast(&self, tx: &Transaction) -> Result<(), Error> { + /// 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(); + let txid = tx.compute_txid(); + self.write_broadcast_intent(tx)?; let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - self.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) + let mut locked_wallet = self.inner.lock().unwrap(); + 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 a transaction that was rejected or was provably never dispatched. - pub(crate) fn abandon_pending_broadcast(&self, tx: &Transaction) -> Result<(), Error> { + /// Release and forget a transaction only after a conclusive initial-send outcome. + pub(crate) fn abandon_broadcast_intent(&self, tx: &Transaction) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); let txid = tx.compute_txid(); let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); let mut locked_wallet = self.inner.lock().unwrap(); @@ -839,12 +918,179 @@ impl Wallet { Error::PersistenceFailed })?; drop(locked_wallet); - self.payment_store.remove(&PaymentId(txid.to_byte_array())) + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + self.remove_broadcast_intent(&txid) + } + + /// 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(); + let Some(tx) = self.read_broadcast_intent(txid)? else { + return Ok(None); + }; + let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let mut locked_wallet = self.inner.lock().unwrap(); + 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 }); + } + Ok(Some(tx)) + } + + /// List transaction IDs whose backend acceptance still requires reconciliation. + pub(crate) fn list_pending_broadcasts(&self) -> Result, Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + Ok(self.read_all_broadcast_intents()?.into_iter().map(|tx| tx.compute_txid()).collect()) + } + + /// Remove a resolved broadcast intent without affecting ordinary wallet history. + pub(crate) fn clear_broadcast_intent(&self, txid: &Txid) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.remove_broadcast_intent(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 pending_txs = self.read_all_broadcast_intents()?; + if pending_txs.is_empty() { + return Ok(()); + } + + let mut locked_wallet = self.inner.lock().unwrap(); + let confirmed_txids = + locked_wallet.transaction_confirmations().keys().copied().collect::>(); + let unresolved_txs = pending_txs + .into_iter() + .filter(|tx| !confirmed_txids.contains(&tx.compute_txid())) + .collect::>(); + self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + self.remove_broadcast_intents(confirmed_txids) + } + + fn reapply_unresolved_broadcasts( + &self, locked_wallet: &mut AggregateWallet, + transactions: Vec, + ) -> Result<(), Error> { + if transactions.is_empty() { + return Ok(()); + } + let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let unconfirmed_txs = transactions.into_iter().map(|tx| (tx, last_seen)).collect(); + 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 + }) } - /// Recover the exact persisted transaction bytes for reconciliation or rebroadcast. - pub(crate) fn find_transaction(&self, txid: &Txid) -> Option { - self.inner.lock().unwrap().find_tx(*txid) + fn write_broadcast_intent(&self, tx: &Transaction) -> Result<(), Error> { + let txid = tx.compute_txid(); + let mut bytes = vec![ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION]; + bytes.extend(serialize(tx)); + KVStoreSync::write( + &*self.kv_store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &txid.to_string(), + bytes, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to persist broadcast intent {}: {}", txid, e); + Error::PersistenceFailed + }) + } + + fn read_broadcast_intent(&self, txid: &Txid) -> Result, Error> { + let bytes = match KVStoreSync::read( + &*self.kv_store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &txid.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 {}: {}", txid, e); + return Err(Error::PersistenceFailed); + }, + }; + + if bytes.first() != Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) { + log_error!(self.logger, "Unsupported broadcast intent version for {}", txid); + return Err(Error::PersistenceFailed); + } + let tx = deserialize::(&bytes[1..]).map_err(|e| { + log_error!(self.logger, "Failed to decode broadcast intent {}: {}", txid, e); + Error::PersistenceFailed + })?; + if tx.compute_txid() != *txid { + log_error!(self.logger, "Broadcast intent key does not match transaction {}", txid); + return Err(Error::PersistenceFailed); + } + Ok(Some(tx)) + } + + fn read_all_broadcast_intents(&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 + })?; + self.read_broadcast_intent(&txid)?.ok_or(Error::PersistenceFailed) + }) + .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 + }) + } + + fn remove_broadcast_intents(&self, txids: impl IntoIterator) -> Result<(), Error> { + for txid in txids { + self.remove_broadcast_intent(&txid)?; + } + Ok(()) + } + + fn flush_observed_broadcast_intents(&self) -> Result<(), Error> { + let txids = + self.backend_observed_broadcasts.lock().unwrap().iter().copied().collect::>(); + self.remove_broadcast_intents(txids.iter().copied())?; + let mut observed = self.backend_observed_broadcasts.lock().unwrap(); + for txid in txids { + observed.remove(&txid); + } + Ok(()) } // Bumps the fee of an existing transaction using Replace-By-Fee (RBF). @@ -1019,8 +1265,11 @@ impl Wallet { } pub(crate) fn update_payment_store_for_all_transactions(&self) -> Result<(), Error> { + let _intent = self.broadcast_intent_lock.lock().unwrap(); let locked_wallet = self.inner.lock().unwrap(); - self.update_payment_store(&locked_wallet) + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + self.flush_observed_broadcast_intents() } fn update_payment_store( @@ -2166,6 +2415,8 @@ impl Listen for Wallet { } fn block_connected(&self, block: &bitcoin::Block, height: u32) { + let _intent = self.broadcast_intent_lock.lock().unwrap(); + let observed_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); let pre_checkpoint = locked_wallet.latest_checkpoint(); @@ -2187,6 +2438,10 @@ impl Listen for Wallet { log_error!(self.logger, "Failed to update payment store: {}", e); return; } + drop(locked_wallet); + if let Err(e) = self.remove_broadcast_intents(observed_txids) { + log_error!(self.logger, "Failed to reconcile confirmed broadcasts: {}", e); + } }, Err(e) => { log_error!( From bff7f68239abf4906d9737d184fc6ff17d4374c2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 12:20:54 +0200 Subject: [PATCH 10/30] fix: require backend evidence before reconciliation --- src/lib.rs | 18 ++++++++++++++++++ src/wallet/mod.rs | 5 ++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index fa9cc2fa7f..02d6b74d0b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2663,6 +2663,24 @@ mod tests { 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)); + } + #[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()); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8753c645d1..8bce668cd7 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -98,10 +98,9 @@ fn validate_derivation_range(start_index: u32, count: u32) -> Result<(), Error> fn backend_observed_txids(update: &Update) -> HashSet { update .tx_update - .txs + .anchors .iter() - .map(|tx| tx.compute_txid()) - .chain(update.tx_update.anchors.iter().map(|(_, txid)| *txid)) + .map(|(_, txid)| *txid) .chain(update.tx_update.seen_ats.iter().map(|(txid, _)| *txid)) .collect() } From d68125365129e6cff18dcf0e058a0af7c86cf68d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 12:24:39 +0200 Subject: [PATCH 11/30] fix: bound broadcast intent reconciliation --- src/lib.rs | 52 +++++++++++++++++++++-- src/wallet/mod.rs | 103 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 135 insertions(+), 20 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 02d6b74d0b..14b6f8c75b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2478,13 +2478,14 @@ mod tests { use std::future::Future; use std::pin::Pin; use std::str::FromStr; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; use bitcoin::absolute::LockTime; + use bitcoin::hashes::Hash; use bitcoin::transaction::Version; - use bitcoin::{Network, Transaction}; + use bitcoin::{Network, Transaction, Txid}; use lightning::io; use lightning::util::persist::{KVStore, KVStoreSync}; @@ -2496,16 +2497,31 @@ mod tests { struct FailNextWriteStore { inner: InMemoryStore, fail_next_write: AtomicBool, + broadcast_intent_removes: AtomicUsize, } impl FailNextWriteStore { fn new() -> Self { - Self { inner: InMemoryStore::new(), fail_next_write: AtomicBool::new(false) } + Self { + inner: InMemoryStore::new(), + fail_next_write: AtomicBool::new(false), + broadcast_intent_removes: AtomicUsize::new(0), + } } fn fail_next_write(&self) { self.fail_next_write.store(true, Ordering::Relaxed); } + + fn broadcast_intent_remove_count(&self) -> usize { + self.broadcast_intent_removes.load(Ordering::Relaxed) + } + + fn count_broadcast_intent_remove(&self, primary_namespace: &str) { + if primary_namespace == crate::io::ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE { + self.broadcast_intent_removes.fetch_add(1, Ordering::Relaxed); + } + } } impl KVStore for FailNextWriteStore { @@ -2529,6 +2545,7 @@ mod tests { fn remove( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> Pin> + 'static + Send>> { + self.count_broadcast_intent_remove(primary_namespace); KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) } @@ -2558,6 +2575,7 @@ mod tests { fn remove( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, ) -> io::Result<()> { + self.count_broadcast_intent_remove(primary_namespace); KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) } @@ -2681,6 +2699,34 @@ mod tests { 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()); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8bce668cd7..13fc6fa64e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -154,6 +154,7 @@ pub(crate) struct Wallet { inner: Mutex>, // Serializes raw-intent persistence with wallet reservation and backend reconciliation. broadcast_intent_lock: Mutex<()>, + broadcast_intents: Mutex>, backend_observed_broadcasts: Mutex>, // Serializes account membership, primary selection, and account reloads. operation_lock: Mutex<()>, @@ -196,6 +197,7 @@ impl Wallet { Self { inner, broadcast_intent_lock: Mutex::new(()), + broadcast_intents: Mutex::new(HashMap::new()), backend_observed_broadcasts: Mutex::new(HashSet::new()), operation_lock, account_generation: AtomicU64::new(0), @@ -370,7 +372,17 @@ impl Wallet { &self, account: OnchainWalletAccount, block: &bitcoin::Block, height: u32, ) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); - let observed_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); + let pending_txids = self + .read_all_broadcast_intents()? + .into_iter() + .map(|tx| tx.compute_txid()) + .collect::>(); + let observed_txids = block + .txdata + .iter() + .map(|tx| tx.compute_txid()) + .filter(|txid| pending_txids.contains(txid)) + .collect::>(); 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| { @@ -798,9 +810,14 @@ impl Wallet { ) -> Result, Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); let update = update.into(); - let observed_txids = backend_observed_txids(&update); - let unresolved_txs = self - .read_all_broadcast_intents()? + let authoritative_txids = backend_observed_txids(&update); + let pending_txs = self.read_all_broadcast_intents()?; + let observed_txids = pending_txs + .iter() + .map(|tx| tx.compute_txid()) + .filter(|txid| authoritative_txids.contains(txid)) + .collect::>(); + let unresolved_txs = pending_txs .into_iter() .filter(|tx| !observed_txids.contains(&tx.compute_txid())) .collect::>(); @@ -831,9 +848,14 @@ impl Wallet { let _op = self.operation_lock.lock().unwrap(); let _intent = self.broadcast_intent_lock.lock().unwrap(); let update = update.into(); - let observed_txids = backend_observed_txids(&update); - let unresolved_txs = self - .read_all_broadcast_intents()? + let authoritative_txids = backend_observed_txids(&update); + let pending_txs = self.read_all_broadcast_intents()?; + let observed_txids = pending_txs + .iter() + .map(|tx| tx.compute_txid()) + .filter(|txid| authoritative_txids.contains(txid)) + .collect::>(); + let unresolved_txs = pending_txs .into_iter() .filter(|tx| !observed_txids.contains(&tx.compute_txid())) .collect::>(); @@ -868,10 +890,15 @@ impl Wallet { &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); - let observed_txids = + let authoritative_txids = unconfirmed_txs.iter().map(|(tx, _)| tx.compute_txid()).collect::>(); - let unresolved_txs = self - .read_all_broadcast_intents()? + let pending_txs = self.read_all_broadcast_intents()?; + let observed_txids = pending_txs + .iter() + .map(|tx| tx.compute_txid()) + .filter(|txid| authoritative_txids.contains(txid)) + .collect::>(); + let unresolved_txs = pending_txs .into_iter() .filter(|tx| !observed_txids.contains(&tx.compute_txid())) .collect::>(); @@ -958,7 +985,11 @@ impl Wallet { /// 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 pending_txs = self.read_all_broadcast_intents()?; + let pending_txs = self.load_broadcast_intents_from_store()?; + self.broadcast_intents + .lock() + .unwrap() + .extend(pending_txs.iter().cloned().map(|tx| (tx.compute_txid(), tx))); if pending_txs.is_empty() { return Ok(()); } @@ -966,14 +997,19 @@ impl Wallet { let mut locked_wallet = self.inner.lock().unwrap(); let confirmed_txids = locked_wallet.transaction_confirmations().keys().copied().collect::>(); + let confirmed_pending_txids = pending_txs + .iter() + .map(|tx| tx.compute_txid()) + .filter(|txid| confirmed_txids.contains(txid)) + .collect::>(); let unresolved_txs = pending_txs .into_iter() - .filter(|tx| !confirmed_txids.contains(&tx.compute_txid())) + .filter(|tx| !confirmed_pending_txids.contains(&tx.compute_txid())) .collect::>(); self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; self.update_payment_store(&locked_wallet)?; drop(locked_wallet); - self.remove_broadcast_intents(confirmed_txids) + self.remove_broadcast_intents(confirmed_pending_txids) } fn reapply_unresolved_broadcasts( @@ -1005,10 +1041,16 @@ impl Wallet { .map_err(|e| { log_error!(self.logger, "Failed to persist broadcast intent {}: {}", txid, e); Error::PersistenceFailed - }) + })?; + self.broadcast_intents.lock().unwrap().insert(txid, tx.clone()); + Ok(()) } fn read_broadcast_intent(&self, txid: &Txid) -> Result, Error> { + Ok(self.broadcast_intents.lock().unwrap().get(txid).cloned()) + } + + fn read_broadcast_intent_from_store(&self, txid: &Txid) -> Result, Error> { let bytes = match KVStoreSync::read( &*self.kv_store, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, @@ -1039,6 +1081,13 @@ impl Wallet { } fn read_all_broadcast_intents(&self) -> Result, Error> { + let mut transactions = + self.broadcast_intents.lock().unwrap().values().cloned().collect::>(); + transactions.sort_unstable_by_key(|tx| tx.compute_txid()); + Ok(transactions) + } + + fn load_broadcast_intents_from_store(&self) -> Result, Error> { let mut keys = KVStoreSync::list( &*self.kv_store, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, @@ -1055,7 +1104,7 @@ impl Wallet { log_error!(self.logger, "Invalid broadcast intent key {}: {}", key, e); Error::PersistenceFailed })?; - self.read_broadcast_intent(&txid)?.ok_or(Error::PersistenceFailed) + self.read_broadcast_intent_from_store(&txid)?.ok_or(Error::PersistenceFailed) }) .collect() } @@ -1071,7 +1120,9 @@ impl Wallet { .map_err(|e| { log_error!(self.logger, "Failed to remove broadcast intent {}: {}", txid, e); Error::PersistenceFailed - }) + })?; + self.broadcast_intents.lock().unwrap().remove(txid); + Ok(()) } fn remove_broadcast_intents(&self, txids: impl IntoIterator) -> Result<(), Error> { @@ -2415,7 +2466,25 @@ impl Listen for Wallet { fn block_connected(&self, block: &bitcoin::Block, height: u32) { let _intent = self.broadcast_intent_lock.lock().unwrap(); - let observed_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); + let pending_txids = match self.read_all_broadcast_intents() { + Ok(transactions) => { + transactions.into_iter().map(|tx| tx.compute_txid()).collect::>() + }, + Err(e) => { + log_error!( + self.logger, + "Failed to read broadcast intents before block update: {}", + e + ); + return; + }, + }; + let observed_txids = block + .txdata + .iter() + .map(|tx| tx.compute_txid()) + .filter(|txid| pending_txids.contains(txid)) + .collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); let pre_checkpoint = locked_wallet.latest_checkpoint(); From f8613232e9df3fbce56b74d0ef04190836db9585 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 12:42:24 +0200 Subject: [PATCH 12/30] fix: close unresolved broadcast lifecycles --- CHANGELOG.md | 7 +- bindings/ldk_node.udl | 6 +- crates/bdk-wallet-aggregate/src/lib.rs | 43 +- src/payment/onchain.rs | 291 ++++++++++++- src/tx_broadcaster.rs | 130 ++++-- src/wallet/mod.rs | 564 ++++++++++++++++++++----- 6 files changed, 875 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb618e9eb..83ae0c222a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ 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. + recovery after restart. Callers can explicitly abandon an externally reconciled intent, and RBF + replacements use the same result-bearing, durable lifecycle. +- `NodeError` is now a fielded mobile error type so broadcast failures can expose their transaction + ID. Swift error cases no longer contain the legacy generated `message` associated value, and + fieldless Kotlin exceptions have an empty generated `message`; callers should match the error + variant and use its typed fields. - Electrum transaction rejections are now logged as failures instead of successful broadcasts. >>>>>>> 6967d92 (docs: place broadcast fixes in current changelog) - Add keep consumer rules for JNA types UniFFI needs under R8. diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index cd5fb53ddf..790920dd35 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -388,8 +388,10 @@ interface OnchainPayment { Txid send_all_to_address([ByRef]Address address, boolean retain_reserve, FeeRate? fee_rate); [Throws=NodeError] Txid rebroadcast_transaction([ByRef]Txid txid); - [Throws=NodeError] - sequence list_pending_broadcasts(); + [Throws=NodeError] + sequence list_pending_broadcasts(); + [Throws=NodeError] + void abandon_pending_broadcast([ByRef]Txid txid); [Throws=NodeError] Txid bump_fee_by_rbf([ByRef]Txid txid, FeeRate fee_rate); [Throws=NodeError] diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs index 0f78dd089a..388964a87e 100644 --- a/crates/bdk-wallet-aggregate/src/lib.rs +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -629,10 +629,20 @@ where /// /// 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, last_seen: u64) -> Result<(), Error> { - let txid = tx.compute_txid(); + self.abandon_txs(std::slice::from_ref(tx), last_seen) + } + + /// 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], last_seen: u64) -> Result<(), Error> { + let evicted_txs = txs.iter().map(|tx| (tx.compute_txid(), last_seen)).collect::>(); for (key, wallet) in self.wallets.iter_mut() { - wallet.apply_evicted_txs([(txid, last_seen)]); - wallet.cancel_tx(tx); + wallet.apply_evicted_txs(evicted_txs.iter().copied()); + for tx in txs { + wallet.cancel_tx(tx); + } let persister = self.persisters.get_mut(key).ok_or(Error::PersisterNotFound)?; wallet.persist(persister).map_err(|e| { log::error!("Failed to persist wallet {:?}: {}", key, e); @@ -1904,6 +1914,33 @@ mod tests { .any(|output| output.outpoint == tx.input[0].previous_output)); } + #[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(); diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 28efffb8e4..452440c7a9 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -16,7 +16,7 @@ use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::runtime::Runtime; -use crate::tx_broadcaster::TxBroadcastError; +use crate::tx_broadcaster::{ExplicitBroadcastAdmission, TxBroadcastError}; use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; @@ -117,10 +117,19 @@ impl OnchainPayment { Self { wallet, tx_broadcaster, runtime, channel_manager, config, is_running, logger } } - fn dispatch_prepared_transaction(&self, tx: bitcoin::Transaction) -> Result { + fn begin_explicit_broadcast(&self) -> Result { + self.tx_broadcaster.begin_explicit_broadcast().map_err(|_| Error::NotRunning) + } + + fn dispatch_prepared_transaction( + &self, admission: ExplicitBroadcastAdmission, tx: bitcoin::Transaction, + ) -> Result { let txid = tx.compute_txid(); self.wallet.prepare_pending_broadcast(&tx)?; - match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx.clone())) { + match self + .runtime + .block_on(self.tx_broadcaster.broadcast_transaction(admission, tx.clone())) + { Ok(()) => { if let Err(e) = self.wallet.clear_broadcast_intent(&txid) { log_error!( @@ -480,7 +489,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); @@ -605,6 +613,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); @@ -619,7 +628,7 @@ impl OnchainPayment { outpoints, &self.channel_manager, )?; - self.dispatch_prepared_transaction(tx) + self.dispatch_prepared_transaction(admission, tx) } /// Send an on-chain payment to the given address, draining the available funds. @@ -655,6 +664,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 = @@ -672,7 +682,7 @@ impl OnchainPayment { None, &self.channel_manager, )?; - self.dispatch_prepared_transaction(tx) + self.dispatch_prepared_transaction(admission, tx) } /// Rebroadcast a previously prepared on-chain transaction without creating a new spend. @@ -682,7 +692,9 @@ impl OnchainPayment { /// 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. + /// [`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. @@ -690,9 +702,10 @@ impl OnchainPayment { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } + let admission = self.begin_explicit_broadcast()?; let tx = self.wallet.recover_pending_broadcast(txid)?.ok_or(Error::TransactionNotFound)?; - match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(tx)) { + match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(admission, tx)) { Ok(()) => { if let Err(e) = self.wallet.clear_broadcast_intent(txid) { log_error!( @@ -710,13 +723,51 @@ impl OnchainPayment { /// List explicit transaction IDs whose backend acceptance is still unresolved. /// - /// Entries survive process restart and ordinary mempool eviction. A transaction is removed only - /// after a conclusive initial rejection/not-dispatched result, successful explicit broadcast, or - /// observation by the configured backend. + /// Entries survive process restart and ordinary mempool eviction. RBF supersession atomically + /// changes the active transaction ID while preserving predecessor transactions for backend + /// reconciliation. 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> { self.wallet.list_pending_broadcasts() } + /// Abandon an unresolved explicit broadcast after conclusive external reconciliation. + /// + /// Call this only after independently verifying that the transaction and every replacement for + /// the same payment intent are absent from the mempool and chain and will not be rebroadcast by + /// another process. This releases their reserved inputs and removes their pending payment + /// records. If the active transaction is later observed, ordinary wallet sync will record it + /// again. + /// + /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if + /// `txid` is not the active transaction returned by [`Self::list_pending_broadcasts`]. + /// + /// # Safety and trust boundary + /// + /// No Rust memory-safety preconditions apply. The caller must treat the configured backend as + /// insufficient evidence on its own after an acceptance-unknown result and reconcile against an + /// independent authoritative mempool/chain source before abandoning. + /// + /// # Example + /// + /// ``` + /// # use bitcoin::Txid; + /// # use ldk_node::payment::OnchainPayment; + /// # use ldk_node::NodeError; + /// # fn abandon_reconciled( + /// # payment: &OnchainPayment, + /// # txid: &Txid, + /// # ) -> Result<(), NodeError> { + /// payment.abandon_pending_broadcast(txid) + /// # } + /// ``` + pub fn abandon_pending_broadcast(&self, txid: &Txid) -> Result<(), Error> { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + self.wallet.abandon_broadcast_intent_by_txid(txid) + } + /// Bumps the fee of an existing transaction using Replace-By-Fee (RBF). /// /// This allows a previously sent transaction to be replaced with a new version @@ -732,7 +783,7 @@ impl OnchainPayment { /// /// # Returns /// - /// The transaction ID of the new transaction if successful. + /// The replacement transaction ID after the configured backend accepts it. /// /// # Errors /// @@ -742,10 +793,15 @@ impl OnchainPayment { /// * [`Error::CannotRbfFundingTransaction`] - If the transaction is a channel funding transaction /// * [`Error::InvalidFeeRate`] - If the new fee rate is not higher than the original /// * [`Error::OnchainTxCreationFailed`] - If the new transaction couldn't be created + /// * [`Error::OnchainTxBroadcastRejected`] - If the backend conclusively rejects the replacement + /// * [`Error::OnchainTxBroadcastNotDispatched`] - If the replacement did not reach the backend + /// * [`Error::OnchainTxBroadcastFailed`] - If replacement acceptance is unknown + /// * [`Error::OnchainTxBroadcastTimeout`] - If replacement acceptance timed out pub fn bump_fee_by_rbf(&self, txid: &Txid, fee_rate: FeeRate) -> Result { if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } + let admission = self.begin_explicit_broadcast()?; // Pass through to the wallet implementation #[cfg(not(feature = "uniffi"))] @@ -753,7 +809,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(); + match self + .runtime + .block_on(self.tx_broadcaster.broadcast_transaction(admission, replacement)) + { + Ok(()) => { + if let Err(e) = self.wallet.clear_broadcast_intent(&replacement_txid) { + log_error!( + self.logger, + "Failed to clear accepted RBF broadcast intent {}: {}", + replacement_txid, + e + ); + } + Ok(replacement_txid) + }, + Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { + if self.wallet.reject_rbf_broadcast(&replacement_txid).is_err() { + return Err(Error::OnchainTxBroadcastFailed { txid: replacement_txid }); + } + Err(Self::initial_broadcast_error(error, replacement_txid)) + }, + Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { + Err(Self::initial_broadcast_error(error, replacement_txid)) + }, + } } /// Accelerates confirmation of a transaction using Child-Pays-For-Parent (CPFP). @@ -846,12 +929,16 @@ impl OnchainPayment { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::future::Future; + use std::pin::Pin; + use std::sync::{Arc, Condvar, Mutex}; use bitcoin::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::transaction::Version; use bitcoin::{Network, Transaction, Txid}; + use lightning::io; + use lightning::util::persist::{KVStore, KVStoreSync}; use super::OnchainPayment; use crate::builder::NodeBuilder; @@ -862,6 +949,116 @@ mod tests { use crate::types::DynStore; use crate::Node; + #[derive(Default)] + struct BroadcastWriteState { + armed: bool, + blocked: bool, + released: bool, + } + + struct BlockingBroadcastIntentStore { + inner: InMemoryStore, + state: Mutex, + state_changed: Condvar, + } + + impl BlockingBroadcastIntentStore { + fn new() -> Self { + Self { + inner: InMemoryStore::new(), + state: Mutex::new(BroadcastWriteState::default()), + state_changed: Condvar::new(), + } + } + + fn block_next_broadcast_intent_write(&self) { + let mut state = self.state.lock().unwrap(); + *state = BroadcastWriteState { armed: true, blocked: false, released: false }; + } + + fn wait_until_broadcast_intent_write_is_blocked(&self) { + let mut state = self.state.lock().unwrap(); + while !state.blocked { + state = self.state_changed.wait(state).unwrap(); + } + } + + fn release_broadcast_intent_write(&self) { + let mut state = self.state.lock().unwrap(); + state.released = true; + self.state_changed.notify_all(); + } + + fn maybe_block_broadcast_intent_write(&self, primary_namespace: &str) { + if primary_namespace != crate::io::ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE { + return; + } + let mut state = self.state.lock().unwrap(); + if !state.armed { + return; + } + state.blocked = true; + self.state_changed.notify_all(); + while !state.released { + state = self.state_changed.wait(state).unwrap(); + } + state.armed = false; + } + } + + impl KVStore for BlockingBroadcastIntentStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Pin, io::Error>> + 'static + Send>> { + KVStore::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Pin> + 'static + Send>> { + KVStore::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Pin> + 'static + Send>> { + KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Pin, io::Error>> + 'static + Send>> { + KVStore::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + impl KVStoreSync for BlockingBroadcastIntentStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + KVStoreSync::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> io::Result<()> { + self.maybe_block_broadcast_intent_write(primary_namespace); + KVStoreSync::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> io::Result<()> { + KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> io::Result> { + KVStoreSync::list(&self.inner, primary_namespace, secondary_namespace) + } + } + fn test_node(store: Arc) -> Node { let config = Config { network: Network::Regtest, ..Config::default() }; let mut builder = NodeBuilder::from_config(config); @@ -905,8 +1102,10 @@ mod tests { let node = test_node(Arc::clone(&store)); let payment = node.onchain_payment(); let send_tx = tx.clone(); - let initial_call = - tokio::task::spawn_blocking(move || payment.dispatch_prepared_transaction(send_tx)); + 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()]); @@ -944,4 +1143,64 @@ mod tests { assert!(restarted_node.wallet.list_pending_broadcasts().unwrap().is_empty()); *restarted_node.is_running.write().unwrap() = false; } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn retry_rejection_can_be_conclusively_abandoned() { + let store: Arc = Arc::new(InMemoryStore::new()); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let node = test_node(store); + node.wallet.prepare_pending_broadcast(&tx).unwrap(); + *node.is_running.write().unwrap() = true; + + let payment = node.onchain_payment(); + let retry = tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + let request = receivers.recv().await.unwrap(); + request.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); + drop(receivers); + + assert_eq!(retry.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + node.onchain_payment().abandon_pending_broadcast(&txid).unwrap(); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), None); + *node.is_running.write().unwrap() = false; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pre_stop_admission_cannot_enqueue_after_restart_when_prepare_stalls() { + let concrete_store = Arc::new(BlockingBroadcastIntentStore::new()); + let store: Arc = concrete_store.clone(); + let tx = test_transaction(); + let txid = tx.compute_txid(); + let node = test_node(store); + *node.is_running.write().unwrap() = true; + let payment = node.onchain_payment(); + let admission = payment.begin_explicit_broadcast().unwrap(); + concrete_store.block_next_broadcast_intent_write(); + + let dispatch = tokio::task::spawn_blocking(move || { + payment.dispatch_prepared_transaction(admission, tx) + }); + let blocking_store = Arc::clone(&concrete_store); + tokio::task::spawn_blocking(move || { + blocking_store.wait_until_broadcast_intent_write_is_blocked() + }) + .await + .unwrap(); + + node.tx_broadcaster.pause_explicit_broadcasts(); + node.tx_broadcaster.drain_explicit_broadcasts().await; + node.tx_broadcaster.resume_explicit_broadcasts(); + concrete_store.release_broadcast_intent_write(); + + assert_eq!(dispatch.await.unwrap(), Err(Error::OnchainTxBroadcastNotDispatched { txid })); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; + assert!(tokio::time::timeout(std::time::Duration::from_millis(20), receivers.recv()) + .await + .is_err()); + *node.is_running.write().unwrap() = false; + } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index bc110321c5..e553b0652d 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -208,10 +208,15 @@ where ldk_sender: mpsc::UnboundedSender, explicit_sender: mpsc::Sender, queue_receivers: Mutex, - explicit_broadcasts_enabled: std::sync::Mutex, + explicit_broadcast_run: std::sync::Mutex>>, logger: L, } +struct ExplicitBroadcastRun; + +#[derive(Clone)] +pub(crate) struct ExplicitBroadcastAdmission(Arc); + impl TransactionBroadcaster where L::Target: LdkLogger, @@ -225,19 +230,32 @@ where ldk_sender, explicit_sender, queue_receivers, - explicit_broadcasts_enabled: std::sync::Mutex::new(true), + explicit_broadcast_run: std::sync::Mutex::new(Some(Arc::new(ExplicitBroadcastRun))), logger, } } - /// Allows explicit broadcasts for a new node run after the prior queue was drained. + /// Starts a new explicit-broadcast run after the prior queue was drained. pub(crate) fn resume_explicit_broadcasts(&self) { - *self.explicit_broadcasts_enabled.lock().unwrap() = true; + *self.explicit_broadcast_run.lock().unwrap() = Some(Arc::new(ExplicitBroadcastRun)); } - /// Prevents new explicit broadcasts from entering the current run's queue. + /// Invalidates every admission captured for the current run. pub(crate) fn pause_explicit_broadcasts(&self) { - *self.explicit_broadcasts_enabled.lock().unwrap() = false; + *self.explicit_broadcast_run.lock().unwrap() = None; + } + + /// Captures the current run before transaction creation or durable preparation begins. + pub(crate) fn begin_explicit_broadcast( + &self, + ) -> Result { + self.explicit_broadcast_run + .lock() + .unwrap() + .as_ref() + .cloned() + .map(ExplicitBroadcastAdmission) + .ok_or(TxBroadcastError::NotDispatched) } /// Completes every queued explicit request after new enqueue operations have been fenced. @@ -253,20 +271,25 @@ where } pub(crate) async fn broadcast_transaction( - &self, tx: Transaction, + &self, admission: ExplicitBroadcastAdmission, tx: Transaction, ) -> Result<(), TxBroadcastError> { - self.broadcast_transaction_with_timeout(tx, Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS)) - .await + self.broadcast_transaction_with_timeout( + admission, + tx, + Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), + ) + .await } async fn broadcast_transaction_with_timeout( - &self, tx: Transaction, timeout: Duration, + &self, admission: ExplicitBroadcastAdmission, tx: Transaction, timeout: Duration, ) -> Result<(), TxBroadcastError> { let (result_sender, result_receiver) = oneshot::channel(); let (request, explicit_claim) = BroadcastRequest::explicit(vec![tx], result_sender); { - let enabled = self.explicit_broadcasts_enabled.lock().unwrap(); - if !*enabled { + 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)?; @@ -349,7 +372,8 @@ mod tests { #[tokio::test] async fn explicit_broadcast_returns_after_backend_acceptance() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); - let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -363,7 +387,8 @@ mod tests { #[tokio::test] async fn explicit_broadcast_propagates_backend_rejection() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); - let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -377,7 +402,8 @@ mod tests { #[tokio::test] async fn explicit_broadcast_propagates_backend_failure() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); - let broadcast_fut = broadcaster.broadcast_transaction(test_transaction()); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -391,8 +417,12 @@ mod tests { #[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 broadcast_fut = broadcaster - .broadcast_transaction_with_timeout(test_transaction(), Duration::from_millis(10)); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_millis(10), + ); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -407,14 +437,23 @@ mod tests { #[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(test_transaction(), Duration::from_millis(10)) + .broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_millis(10), + ) .await; assert_eq!(cancelled_result, Err(TxBroadcastError::NotDispatched)); let live_tx = test_transaction_with_lock_time(1); - let broadcast_fut = - broadcaster.broadcast_transaction_with_timeout(live_tx.clone(), Duration::from_secs(1)); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + live_tx.clone(), + Duration::from_secs(1), + ); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -431,8 +470,13 @@ mod tests { 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(test_transaction(), Duration::from_secs(1)) + .broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_secs(1), + ) .await }); tokio::task::yield_now().await; @@ -441,8 +485,12 @@ mod tests { assert!(cancelled_task.await.unwrap_err().is_cancelled()); let live_tx = test_transaction_with_lock_time(1); - let broadcast_fut = - broadcaster.broadcast_transaction_with_timeout(live_tx.clone(), Duration::from_secs(1)); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + live_tx.clone(), + Duration::from_secs(1), + ); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -457,8 +505,12 @@ mod tests { #[tokio::test] async fn stopping_worker_fails_queued_explicit_broadcast_without_dispatch() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); - let broadcast_fut = broadcaster - .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + test_transaction(), + Duration::from_secs(1), + ); let stop_fut = async { tokio::task::yield_now().await; let mut receivers = broadcaster.get_broadcast_queue_receivers().await; @@ -472,20 +524,34 @@ mod tests { #[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(test_transaction(), Duration::from_secs(1)) + .broadcast_transaction_with_timeout( + stale_admission, + test_transaction(), + Duration::from_secs(1), + ) .await, Err(TxBroadcastError::NotDispatched) ); - broadcaster.resume_explicit_broadcasts(); let live_tx = test_transaction_with_lock_time(1); - let broadcast_fut = - broadcaster.broadcast_transaction_with_timeout(live_tx.clone(), Duration::from_secs(1)); + let admission = broadcaster.begin_explicit_broadcast().unwrap(); + let broadcast_fut = broadcaster.broadcast_transaction_with_timeout( + admission, + live_tx.clone(), + Duration::from_secs(1), + ); let process_fut = async { let mut receivers = broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); @@ -521,7 +587,11 @@ mod tests { assert_eq!( broadcaster - .broadcast_transaction_with_timeout(test_transaction(), Duration::from_secs(1)) + .broadcast_transaction_with_timeout( + broadcaster.begin_explicit_broadcast().unwrap(), + test_transaction(), + Duration::from_secs(1), + ) .await, Err(TxBroadcastError::NotDispatched) ); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13fc6fa64e..18e6d401d0 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -73,7 +73,44 @@ 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 ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 1; +const LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 1; +const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 2; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct BroadcastIntent { + transactions: Vec, +} + +impl BroadcastIntent { + fn new(tx: Transaction) -> Self { + Self { transactions: vec![tx] } + } + + fn key(&self) -> Txid { + self.transactions.first().expect("broadcast intents are non-empty").compute_txid() + } + + fn active_transaction(&self) -> &Transaction { + self.transactions.last().expect("broadcast intents are non-empty") + } + + fn active_txid(&self) -> Txid { + self.active_transaction().compute_txid() + } + + fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { + let replaces_active = replacement.input.iter().any(|replacement_input| { + self.active_transaction().input.iter().any(|active_input| { + active_input.previous_output == replacement_input.previous_output + }) + }); + if !replaces_active { + return Err(Error::OnchainTxCreationFailed); + } + self.transactions.push(replacement); + Ok(()) + } +} fn validate_derivation_index(index: u32) -> Result<(), Error> { if index > BIP32_MAX_NORMAL_INDEX { @@ -154,7 +191,8 @@ pub(crate) struct Wallet { inner: Mutex>, // Serializes raw-intent persistence with wallet reservation and backend reconciliation. broadcast_intent_lock: Mutex<()>, - broadcast_intents: Mutex>, + // Keyed by the first transaction ID so an RBF replacement can atomically update one record. + broadcast_intents: Mutex>, backend_observed_broadcasts: Mutex>, // Serializes account membership, primary selection, and account reloads. operation_lock: Mutex<()>, @@ -372,16 +410,14 @@ impl Wallet { &self, account: OnchainWalletAccount, block: &bitcoin::Block, height: u32, ) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); - let pending_txids = self - .read_all_broadcast_intents()? - .into_iter() - .map(|tx| tx.compute_txid()) - .collect::>(); - let observed_txids = block - .txdata + let pending_intents = self.read_all_broadcast_intents()?; + let block_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); + let observed_intent_keys = pending_intents .iter() - .map(|tx| tx.compute_txid()) - .filter(|txid| pending_txids.contains(txid)) + .filter(|(_, intent)| { + intent.transactions.iter().any(|tx| block_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) .collect::>(); let mut locked = self.inner.lock().unwrap(); self.payment_store_update_pending.store(true, Ordering::Release); @@ -400,7 +436,7 @@ impl Wallet { } })?; drop(locked); - self.backend_observed_broadcasts.lock().unwrap().extend(observed_txids); + self.backend_observed_broadcasts.lock().unwrap().extend(observed_intent_keys); Ok(()) } @@ -811,22 +847,28 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); let update = update.into(); let authoritative_txids = backend_observed_txids(&update); - let pending_txs = self.read_all_broadcast_intents()?; - let observed_txids = pending_txs + let pending_intents = self.read_all_broadcast_intents()?; + let observed_intent_keys = pending_intents .iter() - .map(|tx| tx.compute_txid()) - .filter(|txid| authoritative_txids.contains(txid)) + .filter(|(_, intent)| { + intent + .transactions + .iter() + .any(|tx| authoritative_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) .collect::>(); - let unresolved_txs = pending_txs + let unresolved_txs = pending_intents .into_iter() - .filter(|tx| !observed_txids.contains(&tx.compute_txid())) - .collect::>(); + .filter(|(key, _)| !observed_intent_keys.contains(key)) + .map(|(_, intent)| intent.active_transaction().clone()) + .collect(); let mut locked_wallet = self.inner.lock().unwrap(); match locked_wallet.apply_update(update) { Ok((events, _txids)) => { self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; drop(locked_wallet); - self.backend_observed_broadcasts.lock().unwrap().extend(observed_txids); + self.backend_observed_broadcasts.lock().unwrap().extend(observed_intent_keys); Ok(events) }, Err(e) => { @@ -849,16 +891,22 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); let update = update.into(); let authoritative_txids = backend_observed_txids(&update); - let pending_txs = self.read_all_broadcast_intents()?; - let observed_txids = pending_txs + let pending_intents = self.read_all_broadcast_intents()?; + let observed_intent_keys = pending_intents .iter() - .map(|tx| tx.compute_txid()) - .filter(|txid| authoritative_txids.contains(txid)) + .filter(|(_, intent)| { + intent + .transactions + .iter() + .any(|tx| authoritative_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) .collect::>(); - let unresolved_txs = pending_txs + let unresolved_txs = pending_intents .into_iter() - .filter(|tx| !observed_txids.contains(&tx.compute_txid())) - .collect::>(); + .filter(|(key, _)| !observed_intent_keys.contains(key)) + .map(|(_, intent)| intent.active_transaction().clone()) + .collect(); let mut locked_wallet = self.inner.lock().unwrap(); if locked_wallet.wallet(&wallet_account).is_none() { return Ok(None); @@ -867,7 +915,7 @@ impl Wallet { Ok((events, _txids)) => { self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; drop(locked_wallet); - self.backend_observed_broadcasts.lock().unwrap().extend(observed_txids); + self.backend_observed_broadcasts.lock().unwrap().extend(observed_intent_keys); Ok(Some(events)) }, Err(e) => { @@ -892,16 +940,22 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); let authoritative_txids = unconfirmed_txs.iter().map(|(tx, _)| tx.compute_txid()).collect::>(); - let pending_txs = self.read_all_broadcast_intents()?; - let observed_txids = pending_txs + let pending_intents = self.read_all_broadcast_intents()?; + let observed_intent_keys = pending_intents .iter() - .map(|tx| tx.compute_txid()) - .filter(|txid| authoritative_txids.contains(txid)) + .filter(|(_, intent)| { + intent + .transactions + .iter() + .any(|tx| authoritative_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) .collect::>(); - let unresolved_txs = pending_txs + let unresolved_txs = pending_intents .into_iter() - .filter(|tx| !observed_txids.contains(&tx.compute_txid())) - .collect::>(); + .filter(|(key, _)| !observed_intent_keys.contains(key)) + .map(|(_, intent)| intent.active_transaction().clone()) + .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| { @@ -911,16 +965,16 @@ impl Wallet { self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; self.update_payment_store(&locked_wallet)?; drop(locked_wallet); - self.remove_broadcast_intents(observed_txids) + self.remove_broadcast_intents(observed_intent_keys) } /// 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(); let txid = tx.compute_txid(); - self.write_broadcast_intent(tx)?; - let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + self.write_broadcast_intent(&BroadcastIntent::new(tx.clone()))?; let mut locked_wallet = self.inner.lock().unwrap(); + let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[txid])?; self.payment_store_update_pending.store(true, Ordering::Release); if let Err(e) = locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) { log_error!(self.logger, "Failed to reserve pending transaction {}: {}", txid, e); @@ -935,17 +989,26 @@ impl Wallet { /// 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(); - let txid = tx.compute_txid(); - let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let (intent_key, intent) = + self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; + let txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); - locked_wallet.abandon_tx(tx, last_seen).map_err(|e| { + let abandoned_at = Self::next_broadcast_timestamp(&locked_wallet, &txids)?; + locked_wallet.abandon_txs(&intent.transactions, abandoned_at).map_err(|e| { log_error!(self.logger, "Failed to abandon pending transaction {}: {}", txid, e); Error::PersistenceFailed })?; drop(locked_wallet); - self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; - self.remove_broadcast_intent(&txid) + for intent_txid in txids { + self.payment_store.remove(&PaymentId(intent_txid.to_byte_array()))?; + } + self.remove_broadcast_intent(&intent_key) } /// Recover and reserve the exact durable transaction bytes for an explicit rebroadcast. @@ -953,11 +1016,12 @@ impl Wallet { &self, txid: &Txid, ) -> Result, Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); - let Some(tx) = self.read_broadcast_intent(txid)? else { + let Some((_, intent)) = self.find_broadcast_intent_by_active_txid(txid) else { return Ok(None); }; - let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let tx = intent.active_transaction().clone(); let mut locked_wallet = self.inner.lock().unwrap(); + let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[*txid])?; self.payment_store_update_pending.store(true, Ordering::Release); if let Err(e) = locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) { log_error!(self.logger, "Failed to restore pending transaction {}: {}", txid, e); @@ -973,43 +1037,49 @@ impl Wallet { /// List transaction IDs whose backend acceptance still requires reconciliation. pub(crate) fn list_pending_broadcasts(&self) -> Result, Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); - Ok(self.read_all_broadcast_intents()?.into_iter().map(|tx| tx.compute_txid()).collect()) + Ok(self + .read_all_broadcast_intents()? + .into_iter() + .map(|(_, intent)| intent.active_txid()) + .collect()) } /// Remove a resolved broadcast intent without affecting ordinary wallet history. pub(crate) fn clear_broadcast_intent(&self, txid: &Txid) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); - self.remove_broadcast_intent(txid) + let (intent_key, _) = + self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; + self.remove_broadcast_intent(&intent_key) } /// 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 pending_txs = self.load_broadcast_intents_from_store()?; - self.broadcast_intents - .lock() - .unwrap() - .extend(pending_txs.iter().cloned().map(|tx| (tx.compute_txid(), tx))); - if pending_txs.is_empty() { + let pending_intents = self.load_broadcast_intents_from_store()?; + self.broadcast_intents.lock().unwrap().extend(pending_intents.iter().cloned()); + if pending_intents.is_empty() { return Ok(()); } let mut locked_wallet = self.inner.lock().unwrap(); let confirmed_txids = locked_wallet.transaction_confirmations().keys().copied().collect::>(); - let confirmed_pending_txids = pending_txs + let confirmed_intent_keys = pending_intents .iter() - .map(|tx| tx.compute_txid()) - .filter(|txid| confirmed_txids.contains(txid)) + .filter(|(_, intent)| { + intent.transactions.iter().any(|tx| confirmed_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) .collect::>(); - let unresolved_txs = pending_txs + let unresolved_txs = pending_intents .into_iter() - .filter(|tx| !confirmed_pending_txids.contains(&tx.compute_txid())) - .collect::>(); + .filter(|(key, _)| !confirmed_intent_keys.contains(key)) + .map(|(_, intent)| intent.active_transaction().clone()) + .collect(); self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; self.update_payment_store(&locked_wallet)?; drop(locked_wallet); - self.remove_broadcast_intents(confirmed_pending_txids) + self.remove_broadcast_intents(confirmed_intent_keys) } fn reapply_unresolved_broadcasts( @@ -1019,7 +1089,8 @@ impl Wallet { if transactions.is_empty() { return Ok(()); } - let last_seen = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let txids = transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let last_seen = Self::next_broadcast_timestamp(locked_wallet, &txids)?; let unconfirmed_txs = transactions.into_iter().map(|tx| (tx, last_seen)).collect(); locked_wallet.apply_mempool_txs(unconfirmed_txs, Vec::new()).map_err(|e| { log_error!(self.logger, "Failed to preserve unresolved broadcast intents: {}", e); @@ -1027,67 +1098,148 @@ impl Wallet { }) } - fn write_broadcast_intent(&self, tx: &Transaction) -> Result<(), Error> { - let txid = tx.compute_txid(); + 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).ok_or(Error::WalletOperationFailed) + } + + fn write_broadcast_intent(&self, intent: &BroadcastIntent) -> Result<(), Error> { + let intent_key = intent.key(); let mut bytes = vec![ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION]; - bytes.extend(serialize(tx)); + bytes.extend(serialize(&intent.transactions)); KVStoreSync::write( &*self.kv_store, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, - &txid.to_string(), + &intent_key.to_string(), bytes, ) .map_err(|e| { - log_error!(self.logger, "Failed to persist broadcast intent {}: {}", txid, e); + log_error!(self.logger, "Failed to persist broadcast intent {}: {}", intent_key, e); Error::PersistenceFailed })?; - self.broadcast_intents.lock().unwrap().insert(txid, tx.clone()); + self.broadcast_intents.lock().unwrap().insert(intent_key, intent.clone()); Ok(()) } - fn read_broadcast_intent(&self, txid: &Txid) -> Result, Error> { - Ok(self.broadcast_intents.lock().unwrap().get(txid).cloned()) + fn find_broadcast_intent_by_active_txid(&self, txid: &Txid) -> Option<(Txid, BroadcastIntent)> { + self.broadcast_intents + .lock() + .unwrap() + .iter() + .find(|(_, intent)| intent.active_txid() == *txid) + .map(|(key, intent)| (*key, intent.clone())) } - fn read_broadcast_intent_from_store(&self, txid: &Txid) -> Result, Error> { + 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, - &txid.to_string(), + &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 {}: {}", txid, e); + log_error!(self.logger, "Failed to read broadcast intent {}: {}", intent_key, e); return Err(Error::PersistenceFailed); }, }; - if bytes.first() != Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) { - log_error!(self.logger, "Unsupported broadcast intent version for {}", txid); + let intent = match bytes.first() { + Some(&LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let tx = deserialize::(&bytes[1..]).map_err(|e| { + log_error!( + self.logger, + "Failed to decode broadcast intent {}: {}", + intent_key, + e + ); + Error::PersistenceFailed + })?; + BroadcastIntent::new(tx) + }, + Some(&ONCHAIN_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 + })?; + if transactions.is_empty() { + log_error!(self.logger, "Broadcast intent {} has no transactions", intent_key); + return Err(Error::PersistenceFailed); + } + BroadcastIntent { transactions } + }, + _ => { + log_error!(self.logger, "Unsupported broadcast intent version for {}", 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); } - let tx = deserialize::(&bytes[1..]).map_err(|e| { - log_error!(self.logger, "Failed to decode broadcast intent {}: {}", txid, e); - Error::PersistenceFailed - })?; - if tx.compute_txid() != *txid { - log_error!(self.logger, "Broadcast intent key does not match transaction {}", txid); - return Err(Error::PersistenceFailed); + for pair in intent.transactions.windows(2) { + let replaces_previous = pair[1].input.iter().any(|replacement_input| { + pair[0].input.iter().any(|previous_input| { + previous_input.previous_output == replacement_input.previous_output + }) + }); + if !replaces_previous { + log_error!( + self.logger, + "Broadcast intent {} has an invalid replacement chain", + intent_key + ); + return Err(Error::PersistenceFailed); + } } - Ok(Some(tx)) + Ok(Some(intent)) } - fn read_all_broadcast_intents(&self) -> Result, Error> { - let mut transactions = - self.broadcast_intents.lock().unwrap().values().cloned().collect::>(); - transactions.sort_unstable_by_key(|tx| tx.compute_txid()); - Ok(transactions) + 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> { + fn load_broadcast_intents_from_store(&self) -> Result, Error> { let mut keys = KVStoreSync::list( &*self.kv_store, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, @@ -1104,7 +1256,10 @@ impl Wallet { log_error!(self.logger, "Invalid broadcast intent key {}: {}", key, e); Error::PersistenceFailed })?; - self.read_broadcast_intent_from_store(&txid)?.ok_or(Error::PersistenceFailed) + let intent = self + .read_broadcast_intent_from_store(&txid)? + .ok_or(Error::PersistenceFailed)?; + Ok((txid, intent)) }) .collect() } @@ -1122,6 +1277,7 @@ impl Wallet { Error::PersistenceFailed })?; self.broadcast_intents.lock().unwrap().remove(txid); + self.backend_observed_broadcasts.lock().unwrap().remove(txid); Ok(()) } @@ -1143,11 +1299,14 @@ impl Wallet { Ok(()) } - // Bumps the fee of an existing transaction using Replace-By-Fee (RBF). - // Returns the txid of the new transaction if successful. - pub(crate) fn bump_fee_by_rbf( + /// Builds, persists, and reserves an RBF replacement before backend dispatch. + /// + /// If `txid` is already an unresolved explicit broadcast, the 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 for backend reconciliation. + pub(crate) fn prepare_rbf_broadcast( &self, txid: &Txid, fee_rate: FeeRate, channel_manager: &ChannelManager, - ) -> Result { + ) -> Result { // Check if this is a funding transaction if self.is_funding_transaction(txid, channel_manager) { log_error!( @@ -1158,6 +1317,8 @@ impl Wallet { return Err(Error::CannotRbfFundingTransaction); } + let _intent = self.broadcast_intent_lock.lock().unwrap(); + let existing_intent = self.find_broadcast_intent_by_active_txid(txid); let mut locked_wallet = self.inner.lock().unwrap(); let (tx, original_fee) = locked_wallet.build_rbf(*txid, fee_rate).map_err(|e| match e { @@ -1193,16 +1354,26 @@ impl Wallet { }, })?; - // Persist wallet changes - locked_wallet.persist_all().map_err(|e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - })?; - - // Extract and broadcast the transaction - self.broadcaster.broadcast_transactions(&[&tx]); - let new_txid = tx.compute_txid(); + let mut replacement_intent = existing_intent + .map(|(_, intent)| intent) + .unwrap_or_else(|| BroadcastIntent::new(tx.clone())); + if replacement_intent.active_txid() != new_txid { + replacement_intent.supersede(tx.clone())?; + } + self.write_broadcast_intent(&replacement_intent)?; + self.backend_observed_broadcasts.lock().unwrap().remove(&replacement_intent.key()); + + let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[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); @@ -1223,7 +1394,57 @@ impl Wallet { new_fee.to_sat().saturating_sub(original_fee.to_sat()) ); - Ok(new_txid) + Ok(tx) + } + + /// 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)?; + let rejected_tx = intent.transactions.pop().ok_or(Error::TransactionNotFound)?; + let restored_tx = intent.transactions.last().cloned(); + + if restored_tx.is_some() { + self.write_broadcast_intent(&intent)?; + } + + let mut locked_wallet = self.inner.lock().unwrap(); + let rejected_at = Self::next_broadcast_timestamp(&locked_wallet, &[*replacement_txid])?; + locked_wallet.abandon_txs(std::slice::from_ref(&rejected_tx), rejected_at).map_err( + |e| { + log_error!( + self.logger, + "Failed to reject RBF replacement {}: {}", + replacement_txid, + e + ); + Error::PersistenceFailed + }, + )?; + if let Some(restored_tx) = restored_tx { + let restored_txid = restored_tx.compute_txid(); + let restored_at = Self::next_broadcast_timestamp(&locked_wallet, &[restored_txid])?; + locked_wallet.apply_mempool_txs(vec![(restored_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(replacement_txid.to_byte_array()))?; + if intent.transactions.is_empty() { + self.remove_broadcast_intent(&intent_key)?; + } + Ok(()) } // Accelerates confirmation of a transaction using Child-Pays-For-Parent (CPFP). @@ -2466,10 +2687,8 @@ impl Listen for Wallet { fn block_connected(&self, block: &bitcoin::Block, height: u32) { let _intent = self.broadcast_intent_lock.lock().unwrap(); - let pending_txids = match self.read_all_broadcast_intents() { - Ok(transactions) => { - transactions.into_iter().map(|tx| tx.compute_txid()).collect::>() - }, + let pending_intents = match self.read_all_broadcast_intents() { + Ok(intents) => intents, Err(e) => { log_error!( self.logger, @@ -2479,11 +2698,13 @@ impl Listen for Wallet { return; }, }; - let observed_txids = block - .txdata + let block_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); + let observed_intent_keys = pending_intents .iter() - .map(|tx| tx.compute_txid()) - .filter(|txid| pending_txids.contains(txid)) + .filter(|(_, intent)| { + intent.transactions.iter().any(|tx| block_txids.contains(&tx.compute_txid())) + }) + .map(|(key, _)| *key) .collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); @@ -2507,7 +2728,7 @@ impl Listen for Wallet { return; } drop(locked_wallet); - if let Err(e) = self.remove_broadcast_intents(observed_txids) { + if let Err(e) = self.remove_broadcast_intents(observed_intent_keys) { log_error!(self.logger, "Failed to reconcile confirmed broadcasts: {}", e); } }, @@ -2756,12 +2977,42 @@ impl ChangeDestinationSource for WalletKeysManager { mod tests { use super::{ additional_input_weight, map_wallet_account_error, validate_derivation_index, - validate_derivation_range, BIP32_MAX_NORMAL_INDEX, MAX_ADDRESS_INFO_BATCH_COUNT, + validate_derivation_range, BroadcastIntent, BIP32_MAX_NORMAL_INDEX, + LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION, MAX_ADDRESS_INFO_BATCH_COUNT, }; + use crate::builder::NodeBuilder; use crate::config::{AddressType, OnchainWalletAccount}; + use crate::io::{ + test_utils::InMemoryStore, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + }; + use crate::types::DynStore; use crate::Error; use bdk_wallet_aggregate::UtxoPsbtInfo; - use bitcoin::{psbt, OutPoint, TxIn, Weight}; + use bitcoin::absolute::LockTime; + use bitcoin::consensus::serialize; + use bitcoin::transaction::Version; + use bitcoin::{psbt, Network, OutPoint, Transaction, TxIn, Weight}; + use lightning::util::persist::KVStoreSync; + use std::sync::Arc; + + fn replacement_test_transaction(lock_time: u32) -> Transaction { + Transaction { + version: Version::TWO, + lock_time: LockTime::from_consensus(lock_time), + input: vec![TxIn { previous_output: OutPoint::null(), ..TxIn::default() }], + output: vec![], + } + } + + fn replacement_test_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() + } #[test] fn derivation_index_validation_rejects_hardened_range() { @@ -2831,4 +3082,89 @@ mod tests { TxIn::default().segwit_weight() + satisfaction_weight ); } + + #[test] + fn rbf_supersession_preserves_the_intent_key_and_changes_only_the_active_txid() { + let original = replacement_test_transaction(1); + let replacement = replacement_test_transaction(2); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original.clone()); + + intent.supersede(replacement.clone()).unwrap(); + + assert_eq!(intent.key(), original_txid); + assert_eq!(intent.active_txid(), replacement_txid); + assert_eq!(intent.transactions, vec![original, replacement]); + } + + #[test] + fn 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 observing_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 mut intent = BroadcastIntent::new(original.clone()); + intent.supersede(replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + node.wallet.apply_mempool_txs(vec![(original, 1)], Vec::new()).unwrap(); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + } + + #[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)); + } } From 92a71c124a78f90faed88d202e2793100a1cff91 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 12:49:12 +0200 Subject: [PATCH 13/30] fix: reconcile ordinary RBF predecessors --- src/wallet/mod.rs | 102 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 15 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 18e6d401d0..bc7e9c9b6d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -74,16 +74,31 @@ 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 ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 2; +const LEGACY_RBF_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 2; +const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 3; #[derive(Clone, Debug, PartialEq, Eq)] struct BroadcastIntent { transactions: Vec, + first_pending_index: u32, } impl BroadcastIntent { fn new(tx: Transaction) -> Self { - Self { transactions: vec![tx] } + Self { transactions: vec![tx], first_pending_index: 0 } + } + + fn replacement( + existing: Option, original: Transaction, replacement: Transaction, + ) -> Result { + let is_new_intent = existing.is_none(); + let mut intent = existing.unwrap_or_else(|| Self::new(original)); + intent.supersede(replacement)?; + if is_new_intent { + // The original was already accepted. Retain it only as reconciliation evidence. + intent.first_pending_index = 1; + } + Ok(intent) } fn key(&self) -> Txid { @@ -98,6 +113,10 @@ impl BroadcastIntent { self.active_transaction().compute_txid() } + fn has_pending_transaction(&self) -> bool { + self.transactions.len() > self.first_pending_index as usize + } + fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { let replaces_active = replacement.input.iter().any(|replacement_input| { self.active_transaction().input.iter().any(|active_input| { @@ -1125,7 +1144,7 @@ impl Wallet { fn write_broadcast_intent(&self, intent: &BroadcastIntent) -> Result<(), Error> { let intent_key = intent.key(); let mut bytes = vec![ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION]; - bytes.extend(serialize(&intent.transactions)); + bytes.extend(serialize(&(intent.first_pending_index, &intent.transactions))); KVStoreSync::write( &*self.kv_store, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, @@ -1180,7 +1199,7 @@ impl Wallet { })?; BroadcastIntent::new(tx) }, - Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + Some(&LEGACY_RBF_BROADCAST_INTENT_SERIALIZATION_VERSION) => { let transactions = deserialize::>(&bytes[1..]).map_err(|e| { log_error!( self.logger, @@ -1194,7 +1213,28 @@ impl Wallet { log_error!(self.logger, "Broadcast intent {} has no transactions", intent_key); return Err(Error::PersistenceFailed); } - BroadcastIntent { transactions } + BroadcastIntent { transactions, first_pending_index: 0 } + }, + Some(&ONCHAIN_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 + })?; + if transactions.is_empty() || first_pending_index as usize >= transactions.len() { + log_error!( + self.logger, + "Broadcast intent {} has no pending transaction", + intent_key + ); + return Err(Error::PersistenceFailed); + } + BroadcastIntent { transactions, first_pending_index } }, _ => { log_error!(self.logger, "Unsupported broadcast intent version for {}", intent_key); @@ -1355,12 +1395,12 @@ impl Wallet { })?; let new_txid = tx.compute_txid(); - let mut replacement_intent = existing_intent - .map(|(_, intent)| intent) - .unwrap_or_else(|| BroadcastIntent::new(tx.clone())); - if replacement_intent.active_txid() != new_txid { - replacement_intent.supersede(tx.clone())?; - } + 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(), + )?; self.write_broadcast_intent(&replacement_intent)?; self.backend_observed_broadcasts.lock().unwrap().remove(&replacement_intent.key()); @@ -1404,7 +1444,9 @@ impl Wallet { .find_broadcast_intent_by_active_txid(replacement_txid) .ok_or(Error::TransactionNotFound)?; let rejected_tx = intent.transactions.pop().ok_or(Error::TransactionNotFound)?; - let restored_tx = intent.transactions.last().cloned(); + let restored_tx = intent.has_pending_transaction().then(|| { + intent.transactions.last().expect("a pending broadcast intent is non-empty").clone() + }); if restored_tx.is_some() { self.write_broadcast_intent(&intent)?; @@ -1441,7 +1483,7 @@ impl Wallet { } drop(locked_wallet); self.payment_store.remove(&PaymentId(replacement_txid.to_byte_array()))?; - if intent.transactions.is_empty() { + if !intent.has_pending_transaction() { self.remove_broadcast_intent(&intent_key)?; } Ok(()) @@ -3098,6 +3140,22 @@ mod tests { 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.first_pending_index, 1); + assert_eq!(intent.transactions, vec![original, replacement]); + } + #[test] fn rbf_supersession_rejects_a_transaction_that_does_not_conflict() { let original = replacement_test_transaction(1); @@ -3136,8 +3194,7 @@ mod tests { let node = replacement_test_node(store); let original = replacement_test_transaction(5); let replacement = replacement_test_transaction(6); - let mut intent = BroadcastIntent::new(original.clone()); - intent.supersede(replacement).unwrap(); + let intent = BroadcastIntent::replacement(None, original.clone(), replacement).unwrap(); node.wallet.write_broadcast_intent(&intent).unwrap(); node.wallet.apply_mempool_txs(vec![(original, 1)], Vec::new()).unwrap(); @@ -3145,6 +3202,21 @@ mod tests { 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 legacy_single_transaction_broadcast_intent_is_restored() { let store = Arc::new(InMemoryStore::new()); From 4ed56dc988a8fb2e29ce32b515c1654040e0cdac Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 12:56:04 +0200 Subject: [PATCH 14/30] fix: persist canonical RBF payment state --- CHANGELOG.md | 3 +- src/data_store.rs | 164 +++++++++++++++++++++++++---- src/wallet/mod.rs | 97 ++++++++++++++--- tests/multi_address_types_tests.rs | 13 +++ 4 files changed, 244 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ae0c222a..29b8ee0614 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ 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. + replacements use the same result-bearing, durable lifecycle while exposing only the canonical + transaction in payment history. - `NodeError` is now a fielded mobile error type so broadcast failures can expose their transaction ID. Swift error cases no longer contain the legacy generated `message` associated value, and fieldless Kotlin exceptions have an empty generated `message`; callers should match the error 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/wallet/mod.rs b/src/wallet/mod.rs index bc7e9c9b6d..72a5a8257b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1444,11 +1444,10 @@ impl Wallet { .find_broadcast_intent_by_active_txid(replacement_txid) .ok_or(Error::TransactionNotFound)?; let rejected_tx = intent.transactions.pop().ok_or(Error::TransactionNotFound)?; - let restored_tx = intent.has_pending_transaction().then(|| { - intent.transactions.last().expect("a pending broadcast intent is non-empty").clone() - }); + let predecessor_tx = intent.transactions.last().cloned(); + let restored_pending = intent.has_pending_transaction(); - if restored_tx.is_some() { + if restored_pending { self.write_broadcast_intent(&intent)?; } @@ -1465,11 +1464,12 @@ impl Wallet { Error::PersistenceFailed }, )?; - if let Some(restored_tx) = restored_tx { - let restored_txid = restored_tx.compute_txid(); + if let Some(predecessor_tx) = predecessor_tx { + let restored_txid = predecessor_tx.compute_txid(); let restored_at = Self::next_broadcast_timestamp(&locked_wallet, &[restored_txid])?; - locked_wallet.apply_mempool_txs(vec![(restored_tx, restored_at)], Vec::new()).map_err( - |e| { + locked_wallet + .apply_mempool_txs(vec![(predecessor_tx, restored_at)], Vec::new()) + .map_err(|e| { log_error!( self.logger, "Failed to restore superseded transaction {}: {}", @@ -1477,13 +1477,12 @@ impl Wallet { e ); Error::PersistenceFailed - }, - )?; + })?; self.update_payment_store(&locked_wallet)?; } drop(locked_wallet); self.payment_store.remove(&PaymentId(replacement_txid.to_byte_array()))?; - if !intent.has_pending_transaction() { + if !restored_pending { self.remove_broadcast_intent(&intent_key)?; } Ok(()) @@ -1675,6 +1674,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(()) } @@ -3026,16 +3044,23 @@ mod tests { use crate::config::{AddressType, OnchainWalletAccount}; use crate::io::{ test_utils::InMemoryStore, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, - ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + }; + use crate::payment::{ + ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::types::DynStore; use crate::Error; use bdk_wallet_aggregate::UtxoPsbtInfo; use bitcoin::absolute::LockTime; use bitcoin::consensus::serialize; + use bitcoin::hashes::Hash; use bitcoin::transaction::Version; use bitcoin::{psbt, Network, OutPoint, Transaction, TxIn, Weight}; + use lightning::ln::channelmanager::PaymentId; use lightning::util::persist::KVStoreSync; + use std::collections::HashSet; use std::sync::Arc; fn replacement_test_transaction(lock_time: u32) -> Transaction { @@ -3056,6 +3081,17 @@ mod tests { 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() { assert_eq!(validate_derivation_index(BIP32_MAX_NORMAL_INDEX), Ok(())); @@ -3217,6 +3253,43 @@ mod tests { assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); } + #[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 legacy_single_transaction_broadcast_intent_is_restored() { let store = Arc::new(InMemoryStore::new()); diff --git a/tests/multi_address_types_tests.rs b/tests/multi_address_types_tests.rs index 060716e419..e903eb460f 100644 --- a/tests/multi_address_types_tests.rs +++ b/tests/multi_address_types_tests.rs @@ -1805,6 +1805,7 @@ mod rbf { use bitcoin::FeeRate; use electrum_client::ElectrumApi; use ldk_node::config::AddressType; + use ldk_node::payment::PaymentKind; use crate::common::{ api_fee_rate, setup_bitcoind_and_electrsd, setup_node, wait_for_tx, TestChainSource, @@ -1903,6 +1904,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!( From f8dc9d8ba4bc3e473618b7ae63205dc572a65213 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 13:09:15 +0200 Subject: [PATCH 15/30] fix: retain accepted RBF ancestry --- src/lib.rs | 2 +- src/wallet/mod.rs | 315 +++++++++++++++++++++-------- tests/multi_address_types_tests.rs | 1 - 3 files changed, 236 insertions(+), 82 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 14b6f8c75b..dfb2f7836a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2675,7 +2675,7 @@ mod tests { update.tx_update.seen_ats.insert((txid, 1)); node.wallet.apply_update(update).unwrap(); - assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + 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()); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 72a5a8257b..6df0b24596 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -80,6 +80,7 @@ const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 3; #[derive(Clone, Debug, PartialEq, Eq)] struct BroadcastIntent { transactions: Vec, + // Transactions before this index are retained lineage. Equality with `len` means resolved. first_pending_index: u32, } @@ -117,6 +118,12 @@ impl BroadcastIntent { self.transactions.len() > self.first_pending_index as usize } + fn mark_resolved(&mut self) -> Result<(), Error> { + self.first_pending_index = + u32::try_from(self.transactions.len()).map_err(|_| Error::WalletOperationFailed)?; + Ok(()) + } + fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { let replaces_active = replacement.input.iter().any(|replacement_input| { self.active_transaction().input.iter().any(|active_input| { @@ -161,6 +168,37 @@ fn backend_observed_txids(update: &Update) -> HashSet { .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)); + } + intent + .transactions + .iter() + .rev() + .map(|tx| tx.compute_txid()) + .find(|txid| observed_txids.contains(txid)) + .map(|txid| (*key, txid, false)) + }) + .collect() +} + fn map_wallet_account_error( wallet_account: OnchainWalletAccount, error: bdk_wallet_aggregate::Error, ) -> Error { @@ -212,7 +250,6 @@ pub(crate) struct Wallet { broadcast_intent_lock: Mutex<()>, // Keyed by the first transaction ID so an RBF replacement can atomically update one record. broadcast_intents: Mutex>, - backend_observed_broadcasts: Mutex>, // Serializes account membership, primary selection, and account reloads. operation_lock: Mutex<()>, account_generation: AtomicU64, @@ -255,7 +292,6 @@ impl Wallet { inner, broadcast_intent_lock: Mutex::new(()), broadcast_intents: Mutex::new(HashMap::new()), - backend_observed_broadcasts: Mutex::new(HashSet::new()), operation_lock, account_generation: AtomicU64::new(0), synced_derived_accounts: Mutex::new(HashSet::new()), @@ -431,13 +467,7 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); let pending_intents = self.read_all_broadcast_intents()?; let block_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); - let observed_intent_keys = pending_intents - .iter() - .filter(|(_, intent)| { - intent.transactions.iter().any(|tx| block_txids.contains(&tx.compute_txid())) - }) - .map(|(key, _)| *key) - .collect::>(); + let resolutions = observed_broadcast_intents(&pending_intents, &block_txids, &block_txids); 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| { @@ -455,8 +485,7 @@ impl Wallet { } })?; drop(locked); - self.backend_observed_broadcasts.lock().unwrap().extend(observed_intent_keys); - Ok(()) + self.resolve_broadcast_intents(resolutions) } pub(crate) fn finish_pending_sync(&self, refresh_payment_store: bool) -> Result<(), Error> { @@ -477,7 +506,7 @@ impl Wallet { })?; } drop(locked); - self.flush_observed_broadcast_intents() + Ok(()) } // Get a drain script for change outputs. @@ -866,20 +895,17 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); 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 observed_intent_keys = pending_intents - .iter() - .filter(|(_, intent)| { - intent - .transactions - .iter() - .any(|tx| authoritative_txids.contains(&tx.compute_txid())) - }) - .map(|(key, _)| *key) - .collect::>(); + let resolutions = + observed_broadcast_intents(&pending_intents, &authoritative_txids, &confirmed_txids); + let observed_intent_keys = + resolutions.iter().map(|(key, _, _)| *key).collect::>(); let unresolved_txs = pending_intents .into_iter() - .filter(|(key, _)| !observed_intent_keys.contains(key)) + .filter(|(key, intent)| { + intent.has_pending_transaction() && !observed_intent_keys.contains(key) + }) .map(|(_, intent)| intent.active_transaction().clone()) .collect(); let mut locked_wallet = self.inner.lock().unwrap(); @@ -887,7 +913,7 @@ impl Wallet { Ok((events, _txids)) => { self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; drop(locked_wallet); - self.backend_observed_broadcasts.lock().unwrap().extend(observed_intent_keys); + self.resolve_broadcast_intents(resolutions)?; Ok(events) }, Err(e) => { @@ -910,20 +936,17 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); 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 observed_intent_keys = pending_intents - .iter() - .filter(|(_, intent)| { - intent - .transactions - .iter() - .any(|tx| authoritative_txids.contains(&tx.compute_txid())) - }) - .map(|(key, _)| *key) - .collect::>(); + let resolutions = + observed_broadcast_intents(&pending_intents, &authoritative_txids, &confirmed_txids); + let observed_intent_keys = + resolutions.iter().map(|(key, _, _)| *key).collect::>(); let unresolved_txs = pending_intents .into_iter() - .filter(|(key, _)| !observed_intent_keys.contains(key)) + .filter(|(key, intent)| { + intent.has_pending_transaction() && !observed_intent_keys.contains(key) + }) .map(|(_, intent)| intent.active_transaction().clone()) .collect(); let mut locked_wallet = self.inner.lock().unwrap(); @@ -934,7 +957,7 @@ impl Wallet { Ok((events, _txids)) => { self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; drop(locked_wallet); - self.backend_observed_broadcasts.lock().unwrap().extend(observed_intent_keys); + self.resolve_broadcast_intents(resolutions)?; Ok(Some(events)) }, Err(e) => { @@ -960,19 +983,15 @@ impl Wallet { let authoritative_txids = unconfirmed_txs.iter().map(|(tx, _)| tx.compute_txid()).collect::>(); let pending_intents = self.read_all_broadcast_intents()?; - let observed_intent_keys = pending_intents - .iter() - .filter(|(_, intent)| { - intent - .transactions - .iter() - .any(|tx| authoritative_txids.contains(&tx.compute_txid())) - }) - .map(|(key, _)| *key) - .collect::>(); + let resolutions = + observed_broadcast_intents(&pending_intents, &authoritative_txids, &HashSet::new()); + let observed_intent_keys = + resolutions.iter().map(|(key, _, _)| *key).collect::>(); let unresolved_txs = pending_intents .into_iter() - .filter(|(key, _)| !observed_intent_keys.contains(key)) + .filter(|(key, intent)| { + intent.has_pending_transaction() && !observed_intent_keys.contains(key) + }) .map(|(_, intent)| intent.active_transaction().clone()) .collect(); let mut locked_wallet = self.inner.lock().unwrap(); @@ -984,7 +1003,7 @@ impl Wallet { self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; self.update_payment_store(&locked_wallet)?; drop(locked_wallet); - self.remove_broadcast_intents(observed_intent_keys) + self.resolve_broadcast_intents(resolutions) } /// Durably reserve and index a signed transaction before backend dispatch. @@ -1016,6 +1035,9 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); let (intent_key, intent) = self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; + if !intent.has_pending_transaction() { + return Err(Error::TransactionNotFound); + } let txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); let abandoned_at = Self::next_broadcast_timestamp(&locked_wallet, &txids)?; @@ -1038,6 +1060,9 @@ impl Wallet { 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 mut locked_wallet = self.inner.lock().unwrap(); let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[*txid])?; @@ -1059,16 +1084,17 @@ impl Wallet { Ok(self .read_all_broadcast_intents()? .into_iter() + .filter(|(_, intent)| intent.has_pending_transaction()) .map(|(_, intent)| intent.active_txid()) .collect()) } - /// Remove a resolved broadcast intent without affecting ordinary wallet history. + /// 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(); - let (intent_key, _) = + let (intent_key, intent) = self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; - self.remove_broadcast_intent(&intent_key) + self.resolve_broadcast_intent(intent_key, intent, *txid, false) } /// Restore unresolved intent reservations after loading the wallet from persistent storage. @@ -1092,7 +1118,9 @@ impl Wallet { .collect::>(); let unresolved_txs = pending_intents .into_iter() - .filter(|(key, _)| !confirmed_intent_keys.contains(key)) + .filter(|(key, intent)| { + intent.has_pending_transaction() && !confirmed_intent_keys.contains(key) + }) .map(|(_, intent)| intent.active_transaction().clone()) .collect(); self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; @@ -1226,10 +1254,10 @@ impl Wallet { ); Error::PersistenceFailed })?; - if transactions.is_empty() || first_pending_index as usize >= transactions.len() { + if transactions.is_empty() || first_pending_index as usize > transactions.len() { log_error!( self.logger, - "Broadcast intent {} has no pending transaction", + "Broadcast intent {} has an invalid pending index", intent_key ); return Err(Error::PersistenceFailed); @@ -1317,7 +1345,6 @@ impl Wallet { Error::PersistenceFailed })?; self.broadcast_intents.lock().unwrap().remove(txid); - self.backend_observed_broadcasts.lock().unwrap().remove(txid); Ok(()) } @@ -1328,22 +1355,55 @@ impl Wallet { Ok(()) } - fn flush_observed_broadcast_intents(&self) -> Result<(), Error> { - let txids = - self.backend_observed_broadcasts.lock().unwrap().iter().copied().collect::>(); - self.remove_broadcast_intents(txids.iter().copied())?; - let mut observed = self.backend_observed_broadcasts.lock().unwrap(); - for txid in txids { - observed.remove(&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> { + let observed_index = intent + .transactions + .iter() + .position(|tx| tx.compute_txid() == observed_txid) + .ok_or(Error::TransactionNotFound)?; + for tx in &intent.transactions { + let txid = tx.compute_txid(); + if txid != observed_txid { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } + + if confirmed { + return self.remove_broadcast_intent(&intent_key); + } + + intent.transactions.truncate(observed_index + 1); + intent.mark_resolved()?; + if intent.transactions.len() == 1 { + self.remove_broadcast_intent(&intent_key) + } else { + self.write_broadcast_intent(&intent) + } + } + /// Builds, persists, and reserves an RBF replacement before backend dispatch. /// - /// If `txid` is already an unresolved explicit broadcast, the 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 for backend reconciliation. + /// 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 { @@ -1402,9 +1462,10 @@ impl Wallet { tx.clone(), )?; self.write_broadcast_intent(&replacement_intent)?; - self.backend_observed_broadcasts.lock().unwrap().remove(&replacement_intent.key()); - let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[new_txid])?; + 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.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); @@ -1446,13 +1507,17 @@ impl Wallet { let rejected_tx = intent.transactions.pop().ok_or(Error::TransactionNotFound)?; let predecessor_tx = intent.transactions.last().cloned(); let restored_pending = intent.has_pending_transaction(); + let retain_lineage = restored_pending || intent.transactions.len() > 1; - if restored_pending { + if retain_lineage { self.write_broadcast_intent(&intent)?; } let mut locked_wallet = self.inner.lock().unwrap(); - let rejected_at = Self::next_broadcast_timestamp(&locked_wallet, &[*replacement_txid])?; + let mut lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + lineage_txids.push(*replacement_txid); + let rejected_at = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; locked_wallet.abandon_txs(std::slice::from_ref(&rejected_tx), rejected_at).map_err( |e| { log_error!( @@ -1482,7 +1547,7 @@ impl Wallet { } drop(locked_wallet); self.payment_store.remove(&PaymentId(replacement_txid.to_byte_array()))?; - if !restored_pending { + if !retain_lineage { self.remove_broadcast_intent(&intent_key)?; } Ok(()) @@ -1581,7 +1646,7 @@ impl Wallet { let locked_wallet = self.inner.lock().unwrap(); self.update_payment_store(&locked_wallet)?; drop(locked_wallet); - self.flush_observed_broadcast_intents() + Ok(()) } fn update_payment_store( @@ -2759,13 +2824,7 @@ impl Listen for Wallet { }, }; let block_txids = block.txdata.iter().map(|tx| tx.compute_txid()).collect::>(); - let observed_intent_keys = pending_intents - .iter() - .filter(|(_, intent)| { - intent.transactions.iter().any(|tx| block_txids.contains(&tx.compute_txid())) - }) - .map(|(key, _)| *key) - .collect::>(); + let resolutions = observed_broadcast_intents(&pending_intents, &block_txids, &block_txids); let mut locked_wallet = self.inner.lock().unwrap(); let pre_checkpoint = locked_wallet.latest_checkpoint(); @@ -2788,7 +2847,7 @@ impl Listen for Wallet { return; } drop(locked_wallet); - if let Err(e) = self.remove_broadcast_intents(observed_intent_keys) { + if let Err(e) = self.resolve_broadcast_intents(resolutions) { log_error!(self.logger, "Failed to reconcile confirmed broadcasts: {}", e); } }, @@ -3057,11 +3116,12 @@ mod tests { use bitcoin::consensus::serialize; use bitcoin::hashes::Hash; use bitcoin::transaction::Version; - use bitcoin::{psbt, Network, OutPoint, Transaction, TxIn, Weight}; + use bitcoin::{psbt, Amount, Network, OutPoint, Transaction, TxIn, TxOut, Weight}; use lightning::ln::channelmanager::PaymentId; use lightning::util::persist::KVStoreSync; use std::collections::HashSet; use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; fn replacement_test_transaction(lock_time: u32) -> Transaction { Transaction { @@ -3192,6 +3252,45 @@ mod tests { assert_eq!(intent.transactions, vec![original, replacement]); } + #[test] + fn same_second_rbf_timestamp_makes_the_replacement_canonical() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(14); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(15); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let original_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(1); + let mut wallet = node.wallet.inner.lock().unwrap(); + wallet.apply_mempool_txs(vec![(original.clone(), original_seen)], Vec::new()).unwrap(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + + let replacement_seen = + super::Wallet::next_broadcast_timestamp(&wallet, &lineage_txids).unwrap(); + wallet + .apply_mempool_txs(vec![(replacement.clone(), replacement_seen)], Vec::new()) + .unwrap(); + assert_eq!(wallet.find_tx(replacement_txid), Some(replacement)); + assert_eq!(wallet.find_tx(original_txid), None); + node.wallet.update_payment_store(&wallet).unwrap(); + drop(wallet); + + assert!(replacement_seen > original_seen); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); + assert!(node.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + } + #[test] fn rbf_supersession_rejects_a_transaction_that_does_not_conflict() { let original = replacement_test_transaction(1); @@ -3290,6 +3389,62 @@ mod tests { .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 original = replacement_test_transaction(16); + let first_replacement = replacement_test_transaction(17); + let second_replacement = replacement_test_transaction(18); + 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, first_replacement, second_replacement] + ); + 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 legacy_single_transaction_broadcast_intent_is_restored() { let store = Arc::new(InMemoryStore::new()); diff --git a/tests/multi_address_types_tests.rs b/tests/multi_address_types_tests.rs index e903eb460f..6701599766 100644 --- a/tests/multi_address_types_tests.rs +++ b/tests/multi_address_types_tests.rs @@ -1887,7 +1887,6 @@ mod rbf { wait_for_tx(&electrsd.client, initial_txid).await; node.sync_wallets().unwrap(); - std::thread::sleep(std::time::Duration::from_secs(1)); let total_before_rbf = node.list_balances().total_onchain_balance_sats; From c507ade3f31cd5cfbc4c9452a81a6d0dc9ab99bb Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 13:25:56 +0200 Subject: [PATCH 16/30] Make broadcast lineage recovery crash-safe --- src/wallet/mod.rs | 804 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 698 insertions(+), 106 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 6df0b24596..0f7dc25fdc 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -75,18 +75,62 @@ 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 ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 3; +const LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 3; +const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 4; +const NO_PREDECESSOR_INDEX: u64 = u64::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BroadcastIntentState { + Pending, + Accepted, + Rejecting, + Abandoning, + Observing, +} + +impl BroadcastIntentState { + fn serialization_tag(self) -> u8 { + match self { + Self::Pending => 0, + Self::Accepted => 1, + Self::Rejecting => 2, + Self::Abandoning => 3, + Self::Observing => 4, + } + } + + 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), + _ => Err(Error::PersistenceFailed), + } + } +} #[derive(Clone, Debug, PartialEq, Eq)] struct BroadcastIntent { transactions: Vec, - // Transactions before this index are retained lineage. Equality with `len` means resolved. - first_pending_index: u32, + active_index: u32, + state: BroadcastIntentState, + // Every replacement records the active lineage member it superseded and whether that member + // still needed reconciliation. This makes rejection rollback durable even after branching. + predecessor_indexes: Vec, + predecessor_was_pending: Vec, } impl BroadcastIntent { fn new(tx: Transaction) -> Self { - Self { transactions: vec![tx], first_pending_index: 0 } + Self { + transactions: vec![tx], + active_index: 0, + state: BroadcastIntentState::Pending, + predecessor_indexes: vec![NO_PREDECESSOR_INDEX], + predecessor_was_pending: vec![0], + } } fn replacement( @@ -94,20 +138,45 @@ impl BroadcastIntent { ) -> Result { let is_new_intent = existing.is_none(); let mut intent = existing.unwrap_or_else(|| Self::new(original)); - intent.supersede(replacement)?; if is_new_intent { // The original was already accepted. Retain it only as reconciliation evidence. - intent.first_pending_index = 1; + intent.state = BroadcastIntentState::Accepted; } + intent.supersede(replacement)?; Ok(intent) } + fn from_legacy( + transactions: Vec, first_pending_index: u32, + ) -> Result { + if transactions.is_empty() || first_pending_index as usize > transactions.len() { + return Err(Error::PersistenceFailed); + } + let active_index = + u32::try_from(transactions.len() - 1).map_err(|_| Error::PersistenceFailed)?; + let mut predecessor_indexes = Vec::with_capacity(transactions.len()); + let mut predecessor_was_pending = Vec::with_capacity(transactions.len()); + predecessor_indexes.push(NO_PREDECESSOR_INDEX); + predecessor_was_pending.push(0); + for index in 1..transactions.len() { + predecessor_indexes + .push(u64::try_from(index - 1).map_err(|_| Error::PersistenceFailed)?); + predecessor_was_pending.push(u8::from(index - 1 >= first_pending_index as usize)); + } + let state = if first_pending_index as usize == transactions.len() { + BroadcastIntentState::Accepted + } else { + BroadcastIntentState::Pending + }; + Ok(Self { transactions, active_index, state, predecessor_indexes, predecessor_was_pending }) + } + fn key(&self) -> Txid { self.transactions.first().expect("broadcast intents are non-empty").compute_txid() } fn active_transaction(&self) -> &Transaction { - self.transactions.last().expect("broadcast intents are non-empty") + &self.transactions[self.active_index as usize] } fn active_txid(&self) -> Txid { @@ -115,16 +184,73 @@ impl BroadcastIntent { } fn has_pending_transaction(&self) -> bool { - self.transactions.len() > self.first_pending_index as usize + self.state == BroadcastIntentState::Pending } - fn mark_resolved(&mut self) -> Result<(), Error> { - self.first_pending_index = - u32::try_from(self.transactions.len()).map_err(|_| Error::WalletOperationFailed)?; + 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_rejection(&mut self) -> Result<(), Error> { + if self.state != BroadcastIntentState::Pending + || self.predecessor_indexes[self.active_index as usize] == NO_PREDECESSOR_INDEX + { + return Err(Error::TransactionNotFound); + } + self.state = BroadcastIntentState::Rejecting; + Ok(()) + } + + fn finish_rejection(&mut self) -> Result { + if self.state != BroadcastIntentState::Rejecting + || self.active_index as usize != self.transactions.len().saturating_sub(1) + { + return Err(Error::PersistenceFailed); + } + let rejected_index = self.active_index as usize; + let predecessor_index = self.predecessor_indexes[rejected_index]; + let predecessor_was_pending = self.predecessor_was_pending[rejected_index] != 0; + if predecessor_index == NO_PREDECESSOR_INDEX { + return Err(Error::PersistenceFailed); + } + let rejected_tx = self.transactions.pop().ok_or(Error::PersistenceFailed)?; + self.predecessor_indexes.pop(); + self.predecessor_was_pending.pop(); + self.active_index = + u32::try_from(predecessor_index).map_err(|_| Error::PersistenceFailed)?; + self.state = if predecessor_was_pending { + BroadcastIntentState::Pending + } else { + BroadcastIntentState::Accepted + }; + Ok(rejected_tx) + } + + fn begin_abandon(&mut self) -> Result<(), Error> { + if self.state != BroadcastIntentState::Pending { + return Err(Error::TransactionNotFound); + } + self.state = BroadcastIntentState::Abandoning; Ok(()) } fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { + if !matches!(self.state, BroadcastIntentState::Pending | BroadcastIntentState::Accepted) { + return Err(Error::WalletOperationFailed); + } let replaces_active = replacement.input.iter().any(|replacement_input| { self.active_transaction().input.iter().any(|active_input| { active_input.previous_output == replacement_input.previous_output @@ -133,9 +259,60 @@ impl BroadcastIntent { if !replaces_active { return Err(Error::OnchainTxCreationFailed); } + let predecessor_index = u64::from(self.active_index); + let predecessor_was_pending = u8::from(self.state == BroadcastIntentState::Pending); self.transactions.push(replacement); + self.predecessor_indexes.push(predecessor_index); + self.predecessor_was_pending.push(predecessor_was_pending); + self.active_index = + u32::try_from(self.transactions.len() - 1).map_err(|_| Error::WalletOperationFailed)?; + self.state = BroadcastIntentState::Pending; Ok(()) } + + fn is_valid(&self) -> bool { + let len = self.transactions.len(); + if len == 0 + || self.predecessor_indexes.len() != len + || self.predecessor_was_pending.len() != len + || self.active_index as usize >= len + || self.predecessor_indexes[0] != NO_PREDECESSOR_INDEX + || self.predecessor_was_pending[0] != 0 + { + return false; + } + if matches!( + self.state, + BroadcastIntentState::Pending + | BroadcastIntentState::Rejecting + | BroadcastIntentState::Abandoning + ) && self.active_index as usize != len - 1 + { + return false; + } + if self.state == BroadcastIntentState::Rejecting + && self.predecessor_indexes[self.active_index as usize] == NO_PREDECESSOR_INDEX + { + return false; + } + for index in 1..len { + let Ok(predecessor_index) = usize::try_from(self.predecessor_indexes[index]) else { + return false; + }; + if predecessor_index >= index || self.predecessor_was_pending[index] > 1 { + return false; + } + let replaces_predecessor = self.transactions[index].input.iter().any(|input| { + self.transactions[predecessor_index].input.iter().any(|predecessor_input| { + predecessor_input.previous_output == input.previous_output + }) + }); + if !replaces_predecessor { + return false; + } + } + true + } } fn validate_derivation_index(index: u32) -> Result<(), Error> { @@ -465,9 +642,11 @@ impl Wallet { &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| { @@ -490,6 +669,7 @@ impl Wallet { 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); @@ -893,12 +1073,14 @@ impl Wallet { &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 @@ -906,7 +1088,7 @@ impl Wallet { .filter(|(key, intent)| { intent.has_pending_transaction() && !observed_intent_keys.contains(key) }) - .map(|(_, intent)| intent.active_transaction().clone()) + .map(|(_, intent)| intent) .collect(); let mut locked_wallet = self.inner.lock().unwrap(); match locked_wallet.apply_update(update) { @@ -934,12 +1116,14 @@ impl Wallet { ) -> 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 @@ -947,7 +1131,7 @@ impl Wallet { .filter(|(key, intent)| { intent.has_pending_transaction() && !observed_intent_keys.contains(key) }) - .map(|(_, intent)| intent.active_transaction().clone()) + .map(|(_, intent)| intent) .collect(); let mut locked_wallet = self.inner.lock().unwrap(); if locked_wallet.wallet(&wallet_account).is_none() { @@ -980,11 +1164,13 @@ impl Wallet { &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 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 @@ -992,7 +1178,7 @@ impl Wallet { .filter(|(key, intent)| { intent.has_pending_transaction() && !observed_intent_keys.contains(key) }) - .map(|(_, intent)| intent.active_transaction().clone()) + .map(|(_, intent)| intent) .collect(); let mut locked_wallet = self.inner.lock().unwrap(); self.payment_store_update_pending.store(true, Ordering::Release); @@ -1009,6 +1195,7 @@ impl Wallet { /// Durably reserve and index a signed transaction before backend dispatch. pub(crate) fn prepare_pending_broadcast(&self, tx: &Transaction) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; let txid = tx.compute_txid(); self.write_broadcast_intent(&BroadcastIntent::new(tx.clone()))?; let mut locked_wallet = self.inner.lock().unwrap(); @@ -1033,16 +1220,28 @@ impl Wallet { /// 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(); - let (intent_key, intent) = + self.complete_broadcast_transitions()?; + let (intent_key, mut intent) = self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; - if !intent.has_pending_transaction() { - return Err(Error::TransactionNotFound); + intent.begin_abandon()?; + self.write_broadcast_intent(&intent)?; + self.complete_abandon(intent_key, intent) + } + + fn complete_abandon(&self, intent_key: Txid, intent: BroadcastIntent) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Abandoning { + return Err(Error::PersistenceFailed); } let txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); let abandoned_at = Self::next_broadcast_timestamp(&locked_wallet, &txids)?; locked_wallet.abandon_txs(&intent.transactions, abandoned_at).map_err(|e| { - log_error!(self.logger, "Failed to abandon pending transaction {}: {}", txid, e); + log_error!( + self.logger, + "Failed to abandon pending transaction {}: {}", + intent.active_txid(), + e + ); Error::PersistenceFailed })?; drop(locked_wallet); @@ -1057,6 +1256,7 @@ impl Wallet { &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); }; @@ -1064,8 +1264,10 @@ impl Wallet { 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 last_seen = Self::next_broadcast_timestamp(&locked_wallet, &[*txid])?; + let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; 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); @@ -1081,6 +1283,7 @@ impl Wallet { /// List transaction IDs whose backend acceptance still requires reconciliation. pub(crate) fn list_pending_broadcasts(&self) -> Result, Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; Ok(self .read_all_broadcast_intents()? .into_iter() @@ -1092,6 +1295,7 @@ impl Wallet { /// Resolve an accepted broadcast while retaining multi-hop RBF lineage until confirmation. pub(crate) fn clear_broadcast_intent(&self, txid: &Txid) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; let (intent_key, intent) = self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; self.resolve_broadcast_intent(intent_key, intent, *txid, false) @@ -1100,8 +1304,24 @@ impl Wallet { /// 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 pending_intents = self.load_broadcast_intents_from_store()?; - self.broadcast_intents.lock().unwrap().extend(pending_intents.iter().cloned()); + 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(()); } @@ -1121,7 +1341,7 @@ impl Wallet { .filter(|(key, intent)| { intent.has_pending_transaction() && !confirmed_intent_keys.contains(key) }) - .map(|(_, intent)| intent.active_transaction().clone()) + .map(|(_, intent)| intent) .collect(); self.reapply_unresolved_broadcasts(&mut locked_wallet, unresolved_txs)?; self.update_payment_store(&locked_wallet)?; @@ -1131,14 +1351,18 @@ impl Wallet { fn reapply_unresolved_broadcasts( &self, locked_wallet: &mut AggregateWallet, - transactions: Vec, + intents: Vec, ) -> Result<(), Error> { - if transactions.is_empty() { + if intents.is_empty() { return Ok(()); } - let txids = transactions.iter().map(|tx| tx.compute_txid()).collect::>(); - let last_seen = Self::next_broadcast_timestamp(locked_wallet, &txids)?; - let unconfirmed_txs = transactions.into_iter().map(|tx| (tx, last_seen)).collect(); + 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 = Self::next_broadcast_timestamp(locked_wallet, &lineage_txids)?; + 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 @@ -1170,9 +1394,19 @@ impl Wallet { } 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.first_pending_index, &intent.transactions))); + bytes.extend(serialize(&( + intent.active_index, + intent.state.serialization_tag(), + &intent.predecessor_indexes, + &intent.predecessor_was_pending, + &intent.transactions, + ))); KVStoreSync::write( &*self.kv_store, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, @@ -1237,13 +1471,9 @@ impl Wallet { ); Error::PersistenceFailed })?; - if transactions.is_empty() { - log_error!(self.logger, "Broadcast intent {} has no transactions", intent_key); - return Err(Error::PersistenceFailed); - } - BroadcastIntent { transactions, first_pending_index: 0 } + BroadcastIntent::from_legacy(transactions, 0)? }, - Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + Some(&LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION) => { let (first_pending_index, transactions) = deserialize::<(u32, Vec)>(&bytes[1..]).map_err(|e| { log_error!( @@ -1254,21 +1484,51 @@ impl Wallet { ); Error::PersistenceFailed })?; - if transactions.is_empty() || first_pending_index as usize > transactions.len() { - log_error!( - self.logger, - "Broadcast intent {} has an invalid pending index", - intent_key - ); - return Err(Error::PersistenceFailed); + BroadcastIntent::from_legacy(transactions, first_pending_index)? + }, + Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + let ( + active_index, + state_tag, + predecessor_indexes, + predecessor_was_pending, + transactions, + ) = deserialize::<(u32, u8, Vec, Vec, Vec)>(&bytes[1..]) + .map_err(|e| { + log_error!( + self.logger, + "Failed to decode broadcast intent {}: {}", + intent_key, + e + ); + Error::PersistenceFailed + })?; + let state = + BroadcastIntentState::from_serialization_tag(state_tag).map_err(|_| { + log_error!( + self.logger, + "Broadcast intent {} has an invalid state", + intent_key + ); + Error::PersistenceFailed + })?; + BroadcastIntent { + transactions, + active_index, + state, + predecessor_indexes, + predecessor_was_pending, } - BroadcastIntent { transactions, first_pending_index } }, _ => { 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, @@ -1277,21 +1537,6 @@ impl Wallet { ); return Err(Error::PersistenceFailed); } - for pair in intent.transactions.windows(2) { - let replaces_previous = pair[1].input.iter().any(|replacement_input| { - pair[0].input.iter().any(|previous_input| { - previous_input.previous_output == replacement_input.previous_output - }) - }); - if !replaces_previous { - log_error!( - self.logger, - "Broadcast intent {} has an invalid replacement chain", - intent_key - ); - return Err(Error::PersistenceFailed); - } - } Ok(Some(intent)) } @@ -1355,6 +1600,85 @@ impl Wallet { Ok(()) } + fn stage_broadcast_resolutions(&self, resolutions: &[(Txid, Txid, bool)]) -> Result<(), Error> { + for (intent_key, observed_txid, _) in resolutions { + let mut intent = self + .broadcast_intents + .lock() + .unwrap() + .get(intent_key) + .cloned() + .ok_or(Error::TransactionNotFound)?; + 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 + ) + }) + .collect::>(); + for (intent_key, intent) in transitions { + match intent.state { + BroadcastIntentState::Rejecting => { + self.complete_rejection(intent_key, intent)?; + }, + BroadcastIntentState::Abandoning => { + self.complete_abandon(intent_key, intent)?; + }, + BroadcastIntentState::Observing => { + self.complete_observation(intent_key, intent)?; + }, + _ => {}, + } + } + Ok(()) + } + + fn complete_observation( + &self, intent_key: Txid, mut intent: BroadcastIntent, + ) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Observing { + return Err(Error::PersistenceFailed); + } + let observed_tx = intent.active_transaction().clone(); + let observed_txid = observed_tx.compute_txid(); + let lineage_txids = + intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); + let mut locked_wallet = self.inner.lock().unwrap(); + let observed_at = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; + locked_wallet.apply_mempool_txs(vec![(observed_tx, observed_at)], Vec::new()).map_err( + |e| { + log_error!( + self.logger, + "Failed to restore observed transaction {}: {}", + observed_txid, + e + ); + Error::PersistenceFailed + }, + )?; + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + + intent.state = BroadcastIntentState::Accepted; + if intent.transactions.len() == 1 { + self.remove_broadcast_intent(&intent_key) + } else { + self.write_broadcast_intent(&intent) + } + } + fn resolve_broadcast_intents( &self, resolutions: impl IntoIterator, ) -> Result<(), Error> { @@ -1374,29 +1698,29 @@ impl Wallet { fn resolve_broadcast_intent( &self, intent_key: Txid, mut intent: BroadcastIntent, observed_txid: Txid, confirmed: bool, ) -> Result<(), Error> { - let observed_index = intent - .transactions - .iter() - .position(|tx| tx.compute_txid() == observed_txid) - .ok_or(Error::TransactionNotFound)?; - for tx in &intent.transactions { - let txid = tx.compute_txid(); - if txid != observed_txid { - self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; - } - } - if confirmed { + for tx in &intent.transactions { + let txid = tx.compute_txid(); + if txid != observed_txid { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } return self.remove_broadcast_intent(&intent_key); } - intent.transactions.truncate(observed_index + 1); - intent.mark_resolved()?; + intent.mark_accepted(observed_txid)?; if intent.transactions.len() == 1 { - self.remove_broadcast_intent(&intent_key) + self.remove_broadcast_intent(&intent_key)?; } else { - self.write_broadcast_intent(&intent) + self.write_broadcast_intent(&intent)?; } + for tx in &intent.transactions { + let txid = tx.compute_txid(); + if txid != observed_txid { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } + Ok(()) } /// Builds, persists, and reserves an RBF replacement before backend dispatch. @@ -1418,6 +1742,7 @@ impl Wallet { } let _intent = self.broadcast_intent_lock.lock().unwrap(); + self.complete_broadcast_transitions()?; let existing_intent = self.find_broadcast_intent_by_active_txid(txid); let mut locked_wallet = self.inner.lock().unwrap(); @@ -1504,53 +1829,63 @@ impl Wallet { let (intent_key, mut intent) = self .find_broadcast_intent_by_active_txid(replacement_txid) .ok_or(Error::TransactionNotFound)?; - let rejected_tx = intent.transactions.pop().ok_or(Error::TransactionNotFound)?; - let predecessor_tx = intent.transactions.last().cloned(); - let restored_pending = intent.has_pending_transaction(); - let retain_lineage = restored_pending || intent.transactions.len() > 1; + intent.begin_rejection()?; + self.write_broadcast_intent(&intent)?; + self.complete_rejection(intent_key, intent) + } - if retain_lineage { - self.write_broadcast_intent(&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 mut lineage_txids = + let lineage_txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); - lineage_txids.push(*replacement_txid); let rejected_at = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; locked_wallet.abandon_txs(std::slice::from_ref(&rejected_tx), rejected_at).map_err( |e| { log_error!( self.logger, "Failed to reject RBF replacement {}: {}", - replacement_txid, + rejected_txid, e ); Error::PersistenceFailed }, )?; - if let Some(predecessor_tx) = predecessor_tx { - let restored_txid = predecessor_tx.compute_txid(); - let restored_at = Self::next_broadcast_timestamp(&locked_wallet, &[restored_txid])?; - 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)?; - } + 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(replacement_txid.to_byte_array()))?; - if !retain_lineage { - self.remove_broadcast_intent(&intent_key)?; + self.payment_store.remove(&PaymentId(rejected_txid.to_byte_array()))?; + + 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) } - Ok(()) } // Accelerates confirmation of a transaction using Child-Pays-For-Parent (CPFP). @@ -1643,6 +1978,7 @@ 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)?; drop(locked_wallet); @@ -2812,6 +3148,10 @@ 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) => { @@ -2825,6 +3165,10 @@ impl Listen for Wallet { }; 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(); @@ -3096,8 +3440,9 @@ impl ChangeDestinationSource for WalletKeysManager { mod tests { use super::{ additional_input_weight, map_wallet_account_error, validate_derivation_index, - validate_derivation_range, BroadcastIntent, BIP32_MAX_NORMAL_INDEX, - LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION, MAX_ADDRESS_INFO_BATCH_COUNT, + validate_derivation_range, BroadcastIntent, BroadcastIntentState, BIP32_MAX_NORMAL_INDEX, + LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION, + LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION, MAX_ADDRESS_INFO_BATCH_COUNT, }; use crate::builder::NodeBuilder; use crate::config::{AddressType, OnchainWalletAccount}; @@ -3248,7 +3593,8 @@ mod tests { assert_eq!(intent.key(), original_txid); assert_eq!(intent.active_txid(), replacement_txid); - assert_eq!(intent.first_pending_index, 1); + assert_eq!(intent.state, BroadcastIntentState::Pending); + assert_eq!(intent.predecessor_was_pending, vec![0, 0]); assert_eq!(intent.transactions, vec![original, replacement]); } @@ -3291,6 +3637,75 @@ mod tests { 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); @@ -3352,6 +3767,67 @@ mod tests { 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()); @@ -3445,6 +3921,93 @@ mod tests { 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(store); + let original = replacement_test_transaction(26); + let first_replacement = replacement_test_transaction(27); + let branch_replacement = replacement_test_transaction(28); + 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) + .unwrap(); + node.wallet.write_broadcast_intent(&branch_intent).unwrap(); + node.payment_store.insert(replacement_test_payment(first_replacement_txid)).unwrap(); + node.payment_store.insert(replacement_test_payment(branch_replacement_txid)).unwrap(); + + node.wallet + .resolve_broadcast_intents([(original_txid, first_replacement_txid, true)]) + .unwrap(); + assert!(node.wallet.read_all_broadcast_intents().unwrap().is_empty()); + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert!(node.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + assert!(node.payment(&PaymentId(first_replacement_txid.to_byte_array())).is_some()); + assert!(node.payment(&PaymentId(branch_replacement_txid.to_byte_array())).is_none()); + } + + #[test] + fn observation_transition_completes_after_restart_without_reapplying_the_replacement() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(31); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(32); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let future_seen = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(60); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(replacement.clone(), future_seen)], Vec::new()) + .unwrap(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + // Simulate a crash after recording the backend observation and before applying its update. + node.wallet.stage_broadcast_resolutions(&[(original_txid, original_txid, false)]).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert!(restarted.wallet.list_pending_broadcasts().unwrap().is_empty()); + let wallet = restarted.wallet.inner.lock().unwrap(); + assert_eq!(wallet.find_tx(original_txid), Some(original.clone())); + assert_eq!(wallet.find_tx(replacement_txid), None); + drop(wallet); + let (_, restored_intent) = + restarted.wallet.find_broadcast_intent_by_active_txid(&original_txid).unwrap(); + assert_eq!(restored_intent.transactions, vec![original, replacement]); + assert_eq!(restored_intent.state, BroadcastIntentState::Accepted); + } + #[test] fn legacy_single_transaction_broadcast_intent_is_restored() { let store = Arc::new(InMemoryStore::new()); @@ -3467,4 +4030,33 @@ mod tests { assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); assert_eq!(node.wallet.recover_pending_broadcast(&txid).unwrap(), Some(tx)); } + + #[test] + fn legacy_resolved_lineage_is_migrated_without_becoming_pending() { + let store = Arc::new(InMemoryStore::new()); + let original = replacement_test_transaction(29); + let replacement = replacement_test_transaction(30); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let transactions = vec![original.clone(), replacement.clone()]; + let mut bytes = vec![LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION]; + bytes.extend(serialize(&(2u32, &transactions))); + KVStoreSync::write( + &*store, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + &original_txid.to_string(), + bytes, + ) + .unwrap(); + let dyn_store: Arc = store; + + let node = replacement_test_node(dyn_store); + + assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); + let (_, intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(intent.transactions, transactions); + assert_eq!(intent.state, BroadcastIntentState::Accepted); + } } From 851019708fd34c50265a77c38f5accefb37a9f1d Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 13:37:59 +0200 Subject: [PATCH 17/30] Persist canonical payment before lineage cleanup --- src/wallet/mod.rs | 326 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 300 insertions(+), 26 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 0f7dc25fdc..617a6cce4e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -86,6 +86,7 @@ enum BroadcastIntentState { Rejecting, Abandoning, Observing, + Confirming, } impl BroadcastIntentState { @@ -96,6 +97,7 @@ impl BroadcastIntentState { Self::Rejecting => 2, Self::Abandoning => 3, Self::Observing => 4, + Self::Confirming => 5, } } @@ -106,6 +108,7 @@ impl BroadcastIntentState { 2 => Ok(Self::Rejecting), 3 => Ok(Self::Abandoning), 4 => Ok(Self::Observing), + 5 => Ok(Self::Confirming), _ => Err(Error::PersistenceFailed), } } @@ -204,6 +207,12 @@ impl BroadcastIntent { 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 @@ -1601,7 +1610,7 @@ impl Wallet { } fn stage_broadcast_resolutions(&self, resolutions: &[(Txid, Txid, bool)]) -> Result<(), Error> { - for (intent_key, observed_txid, _) in resolutions { + for (intent_key, observed_txid, confirmed) in resolutions { let mut intent = self .broadcast_intents .lock() @@ -1609,7 +1618,11 @@ impl Wallet { .get(intent_key) .cloned() .ok_or(Error::TransactionNotFound)?; - intent.begin_observation(*observed_txid)?; + if *confirmed { + intent.begin_confirmation(*observed_txid)?; + } else { + intent.begin_observation(*observed_txid)?; + } self.write_broadcast_intent(&intent)?; } Ok(()) @@ -1625,6 +1638,7 @@ impl Wallet { BroadcastIntentState::Rejecting | BroadcastIntentState::Abandoning | BroadcastIntentState::Observing + | BroadcastIntentState::Confirming ) }) .collect::>(); @@ -1639,12 +1653,48 @@ impl Wallet { BroadcastIntentState::Observing => { self.complete_observation(intent_key, intent)?; }, + BroadcastIntentState::Confirming => { + self.complete_confirmation(intent_key, intent)?; + }, _ => {}, } } Ok(()) } + fn complete_confirmation( + &self, intent_key: Txid, mut intent: BroadcastIntent, + ) -> Result<(), Error> { + if intent.state != BroadcastIntentState::Confirming { + return Err(Error::PersistenceFailed); + } + let confirmed_txid = intent.active_txid(); + let 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); + } + self.update_payment_store(&locked_wallet)?; + drop(locked_wallet); + if self.payment_store.get(&PaymentId(confirmed_txid.to_byte_array())).is_none() { + log_error!( + self.logger, + "Confirmed broadcast {} is missing from the payment store", + confirmed_txid + ); + return Err(Error::PersistenceFailed); + } + for tx in &intent.transactions { + let txid = tx.compute_txid(); + if txid != confirmed_txid { + self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; + } + } + self.remove_broadcast_intent(&intent_key) + } + fn complete_observation( &self, intent_key: Txid, mut intent: BroadcastIntent, ) -> Result<(), Error> { @@ -1699,13 +1749,13 @@ impl Wallet { &self, intent_key: Txid, mut intent: BroadcastIntent, observed_txid: Txid, confirmed: bool, ) -> Result<(), Error> { if confirmed { - for tx in &intent.transactions { - let txid = tx.compute_txid(); - if txid != observed_txid { - self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; - } + if intent.state != BroadcastIntentState::Confirming + || intent.active_txid() != observed_txid + { + intent.begin_confirmation(observed_txid)?; + self.write_broadcast_intent(&intent)?; } - return self.remove_broadcast_intent(&intent_key); + return self.complete_confirmation(intent_key, intent); } intent.mark_accepted(observed_txid)?; @@ -3458,16 +3508,136 @@ mod tests { use crate::Error; use bdk_wallet_aggregate::UtxoPsbtInfo; 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, Network, OutPoint, Transaction, TxIn, TxOut, Weight}; + use bitcoin::{ + psbt, Amount, Block, Network, OutPoint, Transaction, TxIn, TxMerkleNode, TxOut, Weight, + }; + use lightning::io; use lightning::ln::channelmanager::PaymentId; - use lightning::util::persist::KVStoreSync; + use lightning::util::persist::{KVStore, KVStoreSync}; use std::collections::HashSet; + use std::future::Future; + use std::pin::Pin; use std::sync::Arc; + use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; + struct NamespaceFailStore { + inner: InMemoryStore, + fail_next_write_namespace: Mutex>, + fail_next_remove_namespace: Mutex>, + } + + impl NamespaceFailStore { + fn new() -> Self { + Self { + inner: InMemoryStore::new(), + fail_next_write_namespace: Mutex::new(None), + fail_next_remove_namespace: Mutex::new(None), + } + } + + fn fail_next_write_in(&self, primary_namespace: &str) { + *self.fail_next_write_namespace.lock().unwrap() = Some(primary_namespace.to_owned()); + } + + fn fail_next_remove_in(&self, primary_namespace: &str) { + *self.fail_next_remove_namespace.lock().unwrap() = Some(primary_namespace.to_owned()); + } + + fn take_failure(slot: &Mutex>, primary_namespace: &str) -> bool { + let mut slot = slot.lock().unwrap(); + if slot.as_deref() == Some(primary_namespace) { + slot.take(); + true + } else { + false + } + } + + fn write_result(&self, primary_namespace: &str) -> io::Result<()> { + if Self::take_failure(&self.fail_next_write_namespace, primary_namespace) { + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace write failure")) + } else { + Ok(()) + } + } + + fn remove_result(&self, primary_namespace: &str) -> io::Result<()> { + if Self::take_failure(&self.fail_next_remove_namespace, primary_namespace) { + Err(io::Error::new(io::ErrorKind::Other, "Injected namespace remove failure")) + } else { + Ok(()) + } + } + } + + impl KVStore for NamespaceFailStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> Pin>> + Send + 'static>> { + KVStore::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> Pin> + Send + 'static>> { + let result = self.write_result(primary_namespace); + if result.is_err() { + return Box::pin(async move { result }); + } + KVStore::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> Pin> + Send + 'static>> { + let result = self.remove_result(primary_namespace); + if result.is_err() { + return Box::pin(async move { result }); + } + KVStore::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> Pin>> + Send + 'static>> { + KVStore::list(&self.inner, primary_namespace, secondary_namespace) + } + } + + impl KVStoreSync for NamespaceFailStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> io::Result> { + KVStoreSync::read(&self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> io::Result<()> { + self.write_result(primary_namespace)?; + KVStoreSync::write(&self.inner, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> io::Result<()> { + self.remove_result(primary_namespace)?; + KVStoreSync::remove(&self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> io::Result> { + KVStoreSync::list(&self.inner, primary_namespace, secondary_namespace) + } + } + fn replacement_test_transaction(lock_time: u32) -> Transaction { Transaction { version: Version::TWO, @@ -3477,6 +3647,23 @@ mod tests { } } + 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); @@ -3869,9 +4056,20 @@ mod tests { 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 original = replacement_test_transaction(16); - let first_replacement = replacement_test_transaction(17); - let second_replacement = replacement_test_transaction(18); + 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(); @@ -3911,8 +4109,10 @@ mod tests { .unwrap(); assert_eq!( uncertain_lineage.transactions, - vec![original, first_replacement, second_replacement] + 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)]) @@ -3924,10 +4124,21 @@ mod tests { #[test] fn mempool_resolution_retains_branches_until_an_inactive_member_confirms() { let store: Arc = Arc::new(InMemoryStore::new()); - let node = replacement_test_node(store); - let original = replacement_test_transaction(26); - let first_replacement = replacement_test_transaction(27); - let branch_replacement = replacement_test_transaction(28); + 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(); @@ -3949,21 +4160,84 @@ mod tests { 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) - .unwrap(); + 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(first_replacement_txid)).unwrap(); node.payment_store.insert(replacement_test_payment(branch_replacement_txid)).unwrap(); + // Simulate a crash after BDK persisted the inactive member's confirmation and before payment + // indexing or intent removal. The confirmed payment is deliberately absent here. + node.wallet + .stage_broadcast_resolutions(&[(original_txid, first_replacement_txid, true)]) + .unwrap(); + let block = replacement_test_confirmation_block(first_replacement); + node.wallet.inner.lock().unwrap().apply_block(&block, 1).unwrap(); + drop(node); + + let restarted = replacement_test_node(store); + assert!(restarted.wallet.read_all_broadcast_intents().unwrap().is_empty()); + assert!(restarted.wallet.list_pending_broadcasts().unwrap().is_empty()); + assert!(restarted.payment(&PaymentId(original_txid.to_byte_array())).is_none()); + assert!(restarted.payment(&PaymentId(first_replacement_txid.to_byte_array())).is_some()); + assert!(restarted.payment(&PaymentId(branch_replacement_txid.to_byte_array())).is_none()); + } + + #[test] + fn confirmation_transition_retries_payment_and_intent_store_failures_in_order() { + let concrete_store = Arc::new(NamespaceFailStore::new()); + let store: Arc = concrete_store.clone(); + let node = replacement_test_node(store); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(34); + original.input[0].previous_output.vout = 1; + original + .output + .push(TxOut { value: Amount::from_sat(10_000), script_pubkey: script_pubkey.clone() }); + let mut replacement = replacement_test_transaction(35); + replacement.input[0].previous_output.vout = 1; + replacement.output.push(TxOut { value: Amount::from_sat(9_000), script_pubkey }); + let original_txid = original.compute_txid(); + let replacement_txid = replacement.compute_txid(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.clear_broadcast_intent(&replacement_txid).unwrap(); + node.payment_store.insert(replacement_test_payment(original_txid)).unwrap(); + node.payment_store.insert(replacement_test_payment(replacement_txid)).unwrap(); + node.wallet.resolve_broadcast_intents([(original_txid, original_txid, false)]).unwrap(); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_none()); + node.wallet - .resolve_broadcast_intents([(original_txid, first_replacement_txid, true)]) + .stage_broadcast_resolutions(&[(original_txid, replacement_txid, true)]) .unwrap(); + let block = replacement_test_confirmation_block(replacement); + node.wallet.inner.lock().unwrap().apply_block(&block, 1).unwrap(); + + concrete_store.fail_next_write_in(PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE); + assert_eq!( + node.wallet.resolve_broadcast_intents([(original_txid, replacement_txid, true)]), + Err(Error::PersistenceFailed) + ); + let (_, confirming_intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(confirming_intent.state, BroadcastIntentState::Confirming); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_none()); + + concrete_store.fail_next_remove_in(ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE); + assert_eq!(node.wallet.complete_broadcast_transitions(), Err(Error::PersistenceFailed)); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); + let (_, confirming_intent) = + node.wallet.find_broadcast_intent_by_active_txid(&replacement_txid).unwrap(); + assert_eq!(confirming_intent.state, BroadcastIntentState::Confirming); + + node.wallet.complete_broadcast_transitions().unwrap(); assert!(node.wallet.read_all_broadcast_intents().unwrap().is_empty()); - assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); assert!(node.payment(&PaymentId(original_txid.to_byte_array())).is_none()); - assert!(node.payment(&PaymentId(first_replacement_txid.to_byte_array())).is_some()); - assert!(node.payment(&PaymentId(branch_replacement_txid.to_byte_array())).is_none()); + assert!(node.payment(&PaymentId(replacement_txid.to_byte_array())).is_some()); } #[test] From 527bb32b1325f8554c5c888205cba527abf10dc0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 13:44:27 +0200 Subject: [PATCH 18/30] Require durable wallet confirmation before cleanup --- src/wallet/mod.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 617a6cce4e..8791ed7eef 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1669,13 +1669,22 @@ impl Wallet { return Err(Error::PersistenceFailed); } let confirmed_txid = intent.active_txid(); - let locked_wallet = self.inner.lock().unwrap(); + 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() { @@ -3497,9 +3506,9 @@ mod tests { use crate::builder::NodeBuilder; use crate::config::{AddressType, OnchainWalletAccount}; use crate::io::{ - test_utils::InMemoryStore, ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, - ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + test_utils::InMemoryStore, BDK_WALLET_TX_GRAPH_PRIMARY_NAMESPACE, + ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE, ONCHAIN_BROADCAST_INTENT_SECONDARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, @@ -4240,6 +4249,65 @@ mod tests { 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()); From 96a83ab1f7111617e75c9f83085c5bbcdf99f239 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 13:59:12 +0200 Subject: [PATCH 19/30] Regenerate UniFFI bindings --- Package.swift | 4 + .../ldknode/ldk_node.android.kt | 527 +++++++++-- .../ldknode/ldk_node.common.kt | 447 ++++++++-- bindings/python/src/ldk_node/ldk_node.py | 463 +++++++--- bindings/swift/Sources/LDKNode/LDKNode.swift | 819 +++++++----------- 5 files changed, 1484 insertions(+), 776 deletions(-) diff --git a/Package.swift b/Package.swift index 3cf534a9fa..dea1f95596 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,7 @@ let checksum = "3d4ed7123281235353cc7558317c3c15f94e0ff9adcab5fbb1fe35d572662f3d let tag = "v0.7.0-rc.67" <<<<<<< HEAD <<<<<<< HEAD +<<<<<<< HEAD let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" >>>>>>> 049b116 (fix: classify broadcast backend results) ======= @@ -22,6 +23,9 @@ let checksum = "ddbf716ae6d1bdaad8b10fa78abab7af4011956e4eb4d4b002f229dcb94f6c8c ======= let checksum = "61565a56ecf33a1e8634de5e3be7d388f0579565161b1237c56756cc8b6638ce" >>>>>>> e359f19 (chore: refresh exact-head Swift artifact) +======= +let checksum = "42bf21b752b53c02267abf68ef38ba2327a3be8662e391121ff6febac1509a97" +>>>>>>> 262ffd0 (Regenerate UniFFI bindings) 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/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 92de69dca1..1da658554e 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt @@ -1547,6 +1547,12 @@ internal typealias UniffiVTableCallbackInterfaceVssHeaderProviderUniffiByValue = + + + + + + @@ -2610,6 +2616,11 @@ internal interface UniffiLib : Library { `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, ): Unit + fun uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): Unit fun uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp( `ptr`: Pointer?, `txid`: RustBufferByValue, @@ -2676,6 +2687,10 @@ internal interface UniffiLib : Library { `utxosToSpend`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): Long + fun uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( + `ptr`: Pointer?, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs( `ptr`: Pointer?, uniffiCallStatus: UniffiRustCallStatus, @@ -2710,6 +2725,11 @@ internal interface UniffiLib : Library { `addressType`: RustBufferByValue, uniffiCallStatus: UniffiRustCallStatus, ): RustBufferByValue + fun uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction( + `ptr`: Pointer?, + `txid`: RustBufferByValue, + uniffiCallStatus: UniffiRustCallStatus, + ): RustBufferByValue fun uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to( `ptr`: Pointer?, `addressType`: RustBufferByValue, @@ -3466,6 +3486,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_offer_supports_chain( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index( @@ -3484,6 +3506,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address( @@ -3498,6 +3522,8 @@ internal interface UniffiLib : Library { ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type( ): Short + fun uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction( + ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to( ): Short fun uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to_account( @@ -4093,6 +4119,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_offer_supports_chain() != 2135.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4120,6 +4149,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts() != 22129.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -4141,6 +4173,9 @@ private fun uniffiCheckApiChecksums(lib: UniffiLib) { if (lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type() != 62171.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction() != 36642.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to() != 44189.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -8337,6 +8372,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { } + @Throws(NodeException::class) + override fun `abandonPendingBroadcast`(`txid`: Txid) { + callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + } + } + @Throws(NodeException::class) override fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid { return FfiConverterTypeTxid.lift(callWithPointer { @@ -8475,6 +8523,18 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `listPendingBroadcasts`(): List { + return FfiConverterSequenceTypeTxid.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( + it, + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `listSpendableOutputs`(): List { return FfiConverterSequenceTypeSpendableUtxo.lift(callWithPointer { @@ -8565,6 +8625,19 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { }) } + @Throws(NodeException::class) + override fun `rebroadcastTransaction`(`txid`: Txid): Txid { + return FfiConverterTypeTxid.lift(callWithPointer { + uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> + UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction( + it, + FfiConverterTypeTxid.lower(`txid`), + uniffiRustCallStatus, + ) + } + }) + } + @Throws(NodeException::class) override fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) { callWithPointer { @@ -12132,83 +12205,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)) - 69 -> NodeException.OnchainTxBroadcastRejected(FfiConverterString.read(buf)) - 70 -> NodeException.OnchainTxBroadcastFailed(FfiConverterString.read(buf)) - 71 -> NodeException.OnchainTxBroadcastTimeout(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) { @@ -12487,14 +12862,22 @@ object FfiConverterTypeNodeError : FfiConverterRustBuffer { } 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 */ } 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 79772e30ef..c86f05bb1b 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -593,6 +593,9 @@ interface OfferInterface { interface OnchainPaymentInterface { + @Throws(NodeException::class) + fun `abandonPendingBroadcast`(`txid`: Txid) + @Throws(NodeException::class) fun `accelerateByCpfp`(`txid`: Txid, `feeRate`: FeeRate?, `destinationAddress`: Address?): Txid @@ -620,6 +623,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong + @Throws(NodeException::class) + fun `listPendingBroadcasts`(): List + @Throws(NodeException::class) fun `listSpendableOutputs`(): List @@ -641,6 +647,9 @@ interface OnchainPaymentInterface { @Throws(NodeException::class) fun `newAddressInfoForType`(`addressType`: AddressType): AddressInfo + @Throws(NodeException::class) + fun `rebroadcastTransaction`(`txid`: Txid): Txid + @Throws(NodeException::class) fun `revealReceiveAddressesTo`(`addressType`: AddressType, `index`: kotlin.UInt) @@ -1887,149 +1896,443 @@ enum class Network { -sealed class NodeException(message: String): kotlin.Exception(message) { +sealed class NodeException: kotlin.Exception() { - class AlreadyRunning(message: String) : NodeException(message) + class AlreadyRunning( + ) : NodeException() { + override val message + get() = "" + } - class NotRunning(message: String) : NodeException(message) + class NotRunning( + ) : NodeException() { + override val message + get() = "" + } - class OnchainTxCreationFailed(message: String) : NodeException(message) + class OnchainTxCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class ConnectionFailed(message: String) : NodeException(message) + class ConnectionFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvoiceCreationFailed(message: String) : NodeException(message) + class InvoiceCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvoiceRequestCreationFailed(message: String) : NodeException(message) + class InvoiceRequestCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class OfferCreationFailed(message: String) : NodeException(message) + class OfferCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class RefundCreationFailed(message: String) : NodeException(message) + class RefundCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class PaymentSendingFailed(message: String) : NodeException(message) + class PaymentSendingFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvalidCustomTlvs(message: String) : NodeException(message) + class InvalidCustomTlvs( + ) : NodeException() { + override val message + get() = "" + } - class ProbeSendingFailed(message: String) : NodeException(message) + class ProbeSendingFailed( + ) : NodeException() { + override val message + get() = "" + } - class RouteNotFound(message: String) : NodeException(message) + class RouteNotFound( + ) : NodeException() { + override val message + get() = "" + } - class ChannelCreationFailed(message: String) : NodeException(message) + class ChannelCreationFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelClosingFailed(message: String) : NodeException(message) + class ChannelClosingFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelSplicingFailed(message: String) : NodeException(message) + class ChannelSplicingFailed( + ) : NodeException() { + override val message + get() = "" + } - class ChannelConfigUpdateFailed(message: String) : NodeException(message) + class ChannelConfigUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class PersistenceFailed(message: String) : NodeException(message) + class PersistenceFailed( + ) : NodeException() { + override val message + get() = "" + } - class FeerateEstimationUpdateFailed(message: String) : NodeException(message) + class FeerateEstimationUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class FeerateEstimationUpdateTimeout(message: String) : NodeException(message) + class FeerateEstimationUpdateTimeout( + ) : NodeException() { + override val message + get() = "" + } - class WalletOperationFailed(message: String) : NodeException(message) + class WalletOperationFailed( + ) : NodeException() { + override val message + get() = "" + } - class WalletOperationTimeout(message: String) : NodeException(message) + class WalletOperationTimeout( + ) : NodeException() { + override val message + get() = "" + } - class OnchainTxSigningFailed(message: String) : NodeException(message) + class OnchainTxSigningFailed( + ) : NodeException() { + override val message + get() = "" + } - class TxSyncFailed(message: String) : NodeException(message) + class TxSyncFailed( + ) : NodeException() { + override val message + get() = "" + } - class TxSyncTimeout(message: String) : NodeException(message) + class TxSyncTimeout( + ) : NodeException() { + override val message + get() = "" + } - class GossipUpdateFailed(message: String) : NodeException(message) + class GossipUpdateFailed( + ) : NodeException() { + override val message + get() = "" + } - class GossipUpdateTimeout(message: String) : NodeException(message) + class GossipUpdateTimeout( + ) : NodeException() { + override val message + get() = "" + } - class LiquidityRequestFailed(message: String) : NodeException(message) + class LiquidityRequestFailed( + ) : NodeException() { + override val message + get() = "" + } - class UriParameterParsingFailed(message: String) : NodeException(message) + class UriParameterParsingFailed( + ) : NodeException() { + override val message + get() = "" + } - class InvalidAddress(message: String) : NodeException(message) + class InvalidAddress( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSocketAddress(message: String) : NodeException(message) + class InvalidSocketAddress( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPublicKey(message: String) : NodeException(message) + class InvalidPublicKey( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSecretKey(message: String) : NodeException(message) + class InvalidSecretKey( + ) : NodeException() { + override val message + get() = "" + } - class InvalidOfferId(message: String) : NodeException(message) + class InvalidOfferId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNodeId(message: String) : NodeException(message) + class InvalidNodeId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentId(message: String) : NodeException(message) + class InvalidPaymentId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentHash(message: String) : NodeException(message) + class InvalidPaymentHash( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentPreimage(message: String) : NodeException(message) + class InvalidPaymentPreimage( + ) : NodeException() { + override val message + get() = "" + } - class InvalidPaymentSecret(message: String) : NodeException(message) + class InvalidPaymentSecret( + ) : NodeException() { + override val message + get() = "" + } - class InvalidAmount(message: String) : NodeException(message) + class InvalidAmount( + ) : NodeException() { + override val message + get() = "" + } - class InvalidInvoice(message: String) : NodeException(message) + class InvalidInvoice( + ) : NodeException() { + override val message + get() = "" + } - class InvalidOffer(message: String) : NodeException(message) + class InvalidOffer( + ) : NodeException() { + override val message + get() = "" + } - class InvalidRefund(message: String) : NodeException(message) + class InvalidRefund( + ) : NodeException() { + override val message + get() = "" + } - class InvalidChannelId(message: String) : NodeException(message) + class InvalidChannelId( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNetwork(message: String) : NodeException(message) + class InvalidNetwork( + ) : NodeException() { + override val message + get() = "" + } - class InvalidUri(message: String) : NodeException(message) + class InvalidUri( + ) : NodeException() { + override val message + get() = "" + } - class InvalidQuantity(message: String) : NodeException(message) + class InvalidQuantity( + ) : NodeException() { + override val message + get() = "" + } - class InvalidNodeAlias(message: String) : NodeException(message) + class InvalidNodeAlias( + ) : NodeException() { + override val message + get() = "" + } - class InvalidDateTime(message: String) : NodeException(message) + class InvalidDateTime( + ) : NodeException() { + override val message + get() = "" + } - class InvalidFeeRate(message: String) : NodeException(message) + class InvalidFeeRate( + ) : NodeException() { + override val message + get() = "" + } - class DuplicatePayment(message: String) : NodeException(message) + class DuplicatePayment( + ) : NodeException() { + override val message + get() = "" + } - class UnsupportedCurrency(message: String) : NodeException(message) + class UnsupportedCurrency( + ) : NodeException() { + override val message + get() = "" + } - class InsufficientFunds(message: String) : NodeException(message) + class InsufficientFunds( + ) : NodeException() { + override val message + get() = "" + } - class LiquiditySourceUnavailable(message: String) : NodeException(message) + class LiquiditySourceUnavailable( + ) : NodeException() { + override val message + get() = "" + } - class LiquidityFeeTooHigh(message: String) : NodeException(message) + class LiquidityFeeTooHigh( + ) : NodeException() { + override val message + get() = "" + } - class InvalidBlindedPaths(message: String) : NodeException(message) + class InvalidBlindedPaths( + ) : NodeException() { + override val message + get() = "" + } - class AsyncPaymentServicesDisabled(message: String) : NodeException(message) + class AsyncPaymentServicesDisabled( + ) : NodeException() { + override val message + get() = "" + } - class CannotRbfFundingTransaction(message: String) : NodeException(message) + class CannotRbfFundingTransaction( + ) : NodeException() { + override val message + get() = "" + } - class TransactionNotFound(message: String) : NodeException(message) + class TransactionNotFound( + ) : NodeException() { + override val message + get() = "" + } - class TransactionAlreadyConfirmed(message: String) : NodeException(message) + class TransactionAlreadyConfirmed( + ) : NodeException() { + override val message + get() = "" + } - class NoSpendableOutputs(message: String) : NodeException(message) + class NoSpendableOutputs( + ) : NodeException() { + override val message + get() = "" + } + + class CoinSelectionFailed( + ) : NodeException() { + override val message + get() = "" + } - class CoinSelectionFailed(message: String) : NodeException(message) + class InvalidMnemonic( + ) : NodeException() { + override val message + get() = "" + } - class InvalidMnemonic(message: String) : NodeException(message) + class BackgroundSyncNotEnabled( + ) : NodeException() { + override val message + get() = "" + } - class BackgroundSyncNotEnabled(message: String) : NodeException(message) + class AddressTypeAlreadyMonitored( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeAlreadyMonitored(message: String) : NodeException(message) + class AddressTypeIsPrimary( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeIsPrimary(message: String) : NodeException(message) + class AddressTypeNotMonitored( + ) : NodeException() { + override val message + get() = "" + } - class AddressTypeNotMonitored(message: String) : NodeException(message) + class OnchainWalletAccountNotRegistered( + ) : NodeException() { + override val message + get() = "" + } - class OnchainWalletAccountNotRegistered(message: String) : NodeException(message) + class InvalidSeedBytes( + ) : NodeException() { + override val message + get() = "" + } - class InvalidSeedBytes(message: String) : NodeException(message) + class OnchainTxBroadcastRejected( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class OnchainTxBroadcastRejected(message: String) : NodeException(message) + class OnchainTxBroadcastFailed( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class OnchainTxBroadcastFailed(message: String) : NodeException(message) + class OnchainTxBroadcastTimeout( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } - class OnchainTxBroadcastTimeout(message: String) : NodeException(message) + class OnchainTxBroadcastNotDispatched( + val `txid`: Txid, + ) : NodeException() { + override val message + get() = "txid=${ `txid` }" + } } diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index 1fe59b69b3..87722764cd 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -805,6 +805,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_offer_supports_chain() != 2135: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_address_info_for_account_at_index() != 63246: @@ -823,6 +825,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee() != 57218: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts() != 22129: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address() != 37251: @@ -837,6 +841,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type() != 62171: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction() != 36642: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to() != 44189: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to_account() != 53588: @@ -2249,6 +2255,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_free_onchainpayment.restype = None +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast.restype = None _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -2324,6 +2336,11 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_calculate_total_fee.restype = ctypes.c_uint64 +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -2365,6 +2382,12 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_new_address_info_for_type.restype = _UniffiRustBuffer +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction.restype = _UniffiRustBuffer _UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -3389,6 +3412,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_offer_supports_chain.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_offer_supports_chain.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp.restype = ctypes.c_uint16 @@ -3416,6 +3442,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_calculate_total_fee.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_pending_broadcasts.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs.restype = ctypes.c_uint16 @@ -3437,6 +3466,9 @@ class _UniffiVTableCallbackInterfaceVssHeaderProvider(ctypes.Structure): _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_new_address_info_for_type.restype = ctypes.c_uint16 +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction.argtypes = ( +) +_UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_rebroadcast_transaction.restype = ctypes.c_uint16 _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to.argtypes = ( ) _UniffiLib.uniffi_ldk_node_checksum_method_onchainpayment_reveal_receive_addresses_to.restype = ctypes.c_uint16 @@ -6885,6 +6917,8 @@ def write(cls, value: OfferProtocol, buf: _UniffiRustBuffer): class OnchainPaymentProtocol(typing.Protocol): + def abandon_pending_broadcast(self, txid: "Txid"): + raise NotImplementedError def accelerate_by_cpfp(self, txid: "Txid",fee_rate: "typing.Optional[FeeRate]",destination_address: "typing.Optional[Address]"): raise NotImplementedError def address_info_for_account_at_index(self, address_type: "AddressType",account_index: "int",keychain: "KeychainKind",index: "int"): @@ -6903,6 +6937,8 @@ def calculate_send_all_fee(self, address: "Address",retain_reserves: "bool",fee_ raise NotImplementedError def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "typing.Optional[FeeRate]",utxos_to_spend: "typing.Optional[typing.List[SpendableUtxo]]"): raise NotImplementedError + def list_pending_broadcasts(self, ): + raise NotImplementedError def list_spendable_outputs(self, ): raise NotImplementedError def new_address(self, ): @@ -6917,6 +6953,8 @@ def new_address_info_for_account(self, address_type: "AddressType",account_index raise NotImplementedError def new_address_info_for_type(self, address_type: "AddressType"): raise NotImplementedError + def rebroadcast_transaction(self, txid: "Txid"): + raise NotImplementedError def reveal_receive_addresses_to(self, address_type: "AddressType",index: "int"): raise NotImplementedError def reveal_receive_addresses_to_account(self, address_type: "AddressType",account_index: "int",index: "int"): @@ -6954,6 +6992,17 @@ def _make_instance_(cls, pointer): return inst + def abandon_pending_broadcast(self, txid: "Txid") -> None: + _UniffiConverterTypeTxid.check_lower(txid) + + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast,self._uniffi_clone_pointer(), + _UniffiConverterTypeTxid.lower(txid)) + + + + + + def accelerate_by_cpfp(self, txid: "Txid",fee_rate: "typing.Optional[FeeRate]",destination_address: "typing.Optional[Address]") -> "Txid": _UniffiConverterTypeTxid.check_lower(txid) @@ -7125,6 +7174,15 @@ def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "t + def list_pending_broadcasts(self, ) -> "typing.List[Txid]": + return _UniffiConverterSequenceTypeTxid.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts,self._uniffi_clone_pointer(),) + ) + + + + + def list_spendable_outputs(self, ) -> "typing.List[SpendableUtxo]": return _UniffiConverterSequenceTypeSpendableUtxo.lift( _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs,self._uniffi_clone_pointer(),) @@ -7206,6 +7264,18 @@ def new_address_info_for_type(self, address_type: "AddressType") -> "AddressInfo + def rebroadcast_transaction(self, txid: "Txid") -> "Txid": + _UniffiConverterTypeTxid.check_lower(txid) + + return _UniffiConverterTypeTxid.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction,self._uniffi_clone_pointer(), + _UniffiConverterTypeTxid.lower(txid)) + ) + + + + + def reveal_receive_addresses_to(self, address_type: "AddressType",index: "int") -> None: _UniffiConverterTypeAddressType.check_lower(address_type) @@ -13181,360 +13251,521 @@ class NodeError(Exception): class NodeError: # type: ignore class AlreadyRunning(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AlreadyRunning({})".format(repr(str(self))) + return "NodeError.AlreadyRunning({})".format(str(self)) _UniffiTempNodeError.AlreadyRunning = AlreadyRunning # type: ignore class NotRunning(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.NotRunning({})".format(repr(str(self))) + return "NodeError.NotRunning({})".format(str(self)) _UniffiTempNodeError.NotRunning = NotRunning # type: ignore class OnchainTxCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OnchainTxCreationFailed({})".format(repr(str(self))) + return "NodeError.OnchainTxCreationFailed({})".format(str(self)) _UniffiTempNodeError.OnchainTxCreationFailed = OnchainTxCreationFailed # type: ignore class ConnectionFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ConnectionFailed({})".format(repr(str(self))) + return "NodeError.ConnectionFailed({})".format(str(self)) _UniffiTempNodeError.ConnectionFailed = ConnectionFailed # type: ignore class InvoiceCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvoiceCreationFailed({})".format(repr(str(self))) + return "NodeError.InvoiceCreationFailed({})".format(str(self)) _UniffiTempNodeError.InvoiceCreationFailed = InvoiceCreationFailed # type: ignore class InvoiceRequestCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvoiceRequestCreationFailed({})".format(repr(str(self))) + return "NodeError.InvoiceRequestCreationFailed({})".format(str(self)) _UniffiTempNodeError.InvoiceRequestCreationFailed = InvoiceRequestCreationFailed # type: ignore class OfferCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OfferCreationFailed({})".format(repr(str(self))) + return "NodeError.OfferCreationFailed({})".format(str(self)) _UniffiTempNodeError.OfferCreationFailed = OfferCreationFailed # type: ignore class RefundCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.RefundCreationFailed({})".format(repr(str(self))) + return "NodeError.RefundCreationFailed({})".format(str(self)) _UniffiTempNodeError.RefundCreationFailed = RefundCreationFailed # type: ignore class PaymentSendingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.PaymentSendingFailed({})".format(repr(str(self))) + return "NodeError.PaymentSendingFailed({})".format(str(self)) _UniffiTempNodeError.PaymentSendingFailed = PaymentSendingFailed # type: ignore class InvalidCustomTlvs(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidCustomTlvs({})".format(repr(str(self))) + return "NodeError.InvalidCustomTlvs({})".format(str(self)) _UniffiTempNodeError.InvalidCustomTlvs = InvalidCustomTlvs # type: ignore class ProbeSendingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ProbeSendingFailed({})".format(repr(str(self))) + return "NodeError.ProbeSendingFailed({})".format(str(self)) _UniffiTempNodeError.ProbeSendingFailed = ProbeSendingFailed # type: ignore class RouteNotFound(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.RouteNotFound({})".format(repr(str(self))) + return "NodeError.RouteNotFound({})".format(str(self)) _UniffiTempNodeError.RouteNotFound = RouteNotFound # type: ignore class ChannelCreationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelCreationFailed({})".format(repr(str(self))) + return "NodeError.ChannelCreationFailed({})".format(str(self)) _UniffiTempNodeError.ChannelCreationFailed = ChannelCreationFailed # type: ignore class ChannelClosingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelClosingFailed({})".format(repr(str(self))) + return "NodeError.ChannelClosingFailed({})".format(str(self)) _UniffiTempNodeError.ChannelClosingFailed = ChannelClosingFailed # type: ignore class ChannelSplicingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelSplicingFailed({})".format(repr(str(self))) + return "NodeError.ChannelSplicingFailed({})".format(str(self)) _UniffiTempNodeError.ChannelSplicingFailed = ChannelSplicingFailed # type: ignore class ChannelConfigUpdateFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.ChannelConfigUpdateFailed({})".format(repr(str(self))) + return "NodeError.ChannelConfigUpdateFailed({})".format(str(self)) _UniffiTempNodeError.ChannelConfigUpdateFailed = ChannelConfigUpdateFailed # type: ignore class PersistenceFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.PersistenceFailed({})".format(repr(str(self))) + return "NodeError.PersistenceFailed({})".format(str(self)) _UniffiTempNodeError.PersistenceFailed = PersistenceFailed # type: ignore class FeerateEstimationUpdateFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.FeerateEstimationUpdateFailed({})".format(repr(str(self))) + return "NodeError.FeerateEstimationUpdateFailed({})".format(str(self)) _UniffiTempNodeError.FeerateEstimationUpdateFailed = FeerateEstimationUpdateFailed # type: ignore class FeerateEstimationUpdateTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.FeerateEstimationUpdateTimeout({})".format(repr(str(self))) + return "NodeError.FeerateEstimationUpdateTimeout({})".format(str(self)) _UniffiTempNodeError.FeerateEstimationUpdateTimeout = FeerateEstimationUpdateTimeout # type: ignore class WalletOperationFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.WalletOperationFailed({})".format(repr(str(self))) + return "NodeError.WalletOperationFailed({})".format(str(self)) _UniffiTempNodeError.WalletOperationFailed = WalletOperationFailed # type: ignore class WalletOperationTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.WalletOperationTimeout({})".format(repr(str(self))) + return "NodeError.WalletOperationTimeout({})".format(str(self)) _UniffiTempNodeError.WalletOperationTimeout = WalletOperationTimeout # type: ignore class OnchainTxSigningFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OnchainTxSigningFailed({})".format(repr(str(self))) + return "NodeError.OnchainTxSigningFailed({})".format(str(self)) _UniffiTempNodeError.OnchainTxSigningFailed = OnchainTxSigningFailed # type: ignore class TxSyncFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TxSyncFailed({})".format(repr(str(self))) + return "NodeError.TxSyncFailed({})".format(str(self)) _UniffiTempNodeError.TxSyncFailed = TxSyncFailed # type: ignore class TxSyncTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TxSyncTimeout({})".format(repr(str(self))) + return "NodeError.TxSyncTimeout({})".format(str(self)) _UniffiTempNodeError.TxSyncTimeout = TxSyncTimeout # type: ignore class GossipUpdateFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.GossipUpdateFailed({})".format(repr(str(self))) + return "NodeError.GossipUpdateFailed({})".format(str(self)) _UniffiTempNodeError.GossipUpdateFailed = GossipUpdateFailed # type: ignore class GossipUpdateTimeout(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.GossipUpdateTimeout({})".format(repr(str(self))) + return "NodeError.GossipUpdateTimeout({})".format(str(self)) _UniffiTempNodeError.GossipUpdateTimeout = GossipUpdateTimeout # type: ignore class LiquidityRequestFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.LiquidityRequestFailed({})".format(repr(str(self))) + return "NodeError.LiquidityRequestFailed({})".format(str(self)) _UniffiTempNodeError.LiquidityRequestFailed = LiquidityRequestFailed # type: ignore class UriParameterParsingFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.UriParameterParsingFailed({})".format(repr(str(self))) + return "NodeError.UriParameterParsingFailed({})".format(str(self)) _UniffiTempNodeError.UriParameterParsingFailed = UriParameterParsingFailed # type: ignore class InvalidAddress(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidAddress({})".format(repr(str(self))) + return "NodeError.InvalidAddress({})".format(str(self)) _UniffiTempNodeError.InvalidAddress = InvalidAddress # type: ignore class InvalidSocketAddress(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidSocketAddress({})".format(repr(str(self))) + return "NodeError.InvalidSocketAddress({})".format(str(self)) _UniffiTempNodeError.InvalidSocketAddress = InvalidSocketAddress # type: ignore class InvalidPublicKey(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPublicKey({})".format(repr(str(self))) + return "NodeError.InvalidPublicKey({})".format(str(self)) _UniffiTempNodeError.InvalidPublicKey = InvalidPublicKey # type: ignore class InvalidSecretKey(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidSecretKey({})".format(repr(str(self))) + return "NodeError.InvalidSecretKey({})".format(str(self)) _UniffiTempNodeError.InvalidSecretKey = InvalidSecretKey # type: ignore class InvalidOfferId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidOfferId({})".format(repr(str(self))) + return "NodeError.InvalidOfferId({})".format(str(self)) _UniffiTempNodeError.InvalidOfferId = InvalidOfferId # type: ignore class InvalidNodeId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidNodeId({})".format(repr(str(self))) + return "NodeError.InvalidNodeId({})".format(str(self)) _UniffiTempNodeError.InvalidNodeId = InvalidNodeId # type: ignore class InvalidPaymentId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentId({})".format(repr(str(self))) + return "NodeError.InvalidPaymentId({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentId = InvalidPaymentId # type: ignore class InvalidPaymentHash(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentHash({})".format(repr(str(self))) + return "NodeError.InvalidPaymentHash({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentHash = InvalidPaymentHash # type: ignore class InvalidPaymentPreimage(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentPreimage({})".format(repr(str(self))) + return "NodeError.InvalidPaymentPreimage({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentPreimage = InvalidPaymentPreimage # type: ignore class InvalidPaymentSecret(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidPaymentSecret({})".format(repr(str(self))) + return "NodeError.InvalidPaymentSecret({})".format(str(self)) _UniffiTempNodeError.InvalidPaymentSecret = InvalidPaymentSecret # type: ignore class InvalidAmount(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidAmount({})".format(repr(str(self))) + return "NodeError.InvalidAmount({})".format(str(self)) _UniffiTempNodeError.InvalidAmount = InvalidAmount # type: ignore class InvalidInvoice(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidInvoice({})".format(repr(str(self))) + return "NodeError.InvalidInvoice({})".format(str(self)) _UniffiTempNodeError.InvalidInvoice = InvalidInvoice # type: ignore class InvalidOffer(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidOffer({})".format(repr(str(self))) + return "NodeError.InvalidOffer({})".format(str(self)) _UniffiTempNodeError.InvalidOffer = InvalidOffer # type: ignore class InvalidRefund(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidRefund({})".format(repr(str(self))) + return "NodeError.InvalidRefund({})".format(str(self)) _UniffiTempNodeError.InvalidRefund = InvalidRefund # type: ignore class InvalidChannelId(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidChannelId({})".format(repr(str(self))) + return "NodeError.InvalidChannelId({})".format(str(self)) _UniffiTempNodeError.InvalidChannelId = InvalidChannelId # type: ignore class InvalidNetwork(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidNetwork({})".format(repr(str(self))) + return "NodeError.InvalidNetwork({})".format(str(self)) _UniffiTempNodeError.InvalidNetwork = InvalidNetwork # type: ignore class InvalidUri(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidUri({})".format(repr(str(self))) + return "NodeError.InvalidUri({})".format(str(self)) _UniffiTempNodeError.InvalidUri = InvalidUri # type: ignore class InvalidQuantity(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidQuantity({})".format(repr(str(self))) + return "NodeError.InvalidQuantity({})".format(str(self)) _UniffiTempNodeError.InvalidQuantity = InvalidQuantity # type: ignore class InvalidNodeAlias(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidNodeAlias({})".format(repr(str(self))) + return "NodeError.InvalidNodeAlias({})".format(str(self)) _UniffiTempNodeError.InvalidNodeAlias = InvalidNodeAlias # type: ignore class InvalidDateTime(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidDateTime({})".format(repr(str(self))) + return "NodeError.InvalidDateTime({})".format(str(self)) _UniffiTempNodeError.InvalidDateTime = InvalidDateTime # type: ignore class InvalidFeeRate(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidFeeRate({})".format(repr(str(self))) + return "NodeError.InvalidFeeRate({})".format(str(self)) _UniffiTempNodeError.InvalidFeeRate = InvalidFeeRate # type: ignore class DuplicatePayment(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.DuplicatePayment({})".format(repr(str(self))) + return "NodeError.DuplicatePayment({})".format(str(self)) _UniffiTempNodeError.DuplicatePayment = DuplicatePayment # type: ignore class UnsupportedCurrency(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.UnsupportedCurrency({})".format(repr(str(self))) + return "NodeError.UnsupportedCurrency({})".format(str(self)) _UniffiTempNodeError.UnsupportedCurrency = UnsupportedCurrency # type: ignore class InsufficientFunds(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InsufficientFunds({})".format(repr(str(self))) + return "NodeError.InsufficientFunds({})".format(str(self)) _UniffiTempNodeError.InsufficientFunds = InsufficientFunds # type: ignore class LiquiditySourceUnavailable(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.LiquiditySourceUnavailable({})".format(repr(str(self))) + return "NodeError.LiquiditySourceUnavailable({})".format(str(self)) _UniffiTempNodeError.LiquiditySourceUnavailable = LiquiditySourceUnavailable # type: ignore class LiquidityFeeTooHigh(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.LiquidityFeeTooHigh({})".format(repr(str(self))) + return "NodeError.LiquidityFeeTooHigh({})".format(str(self)) _UniffiTempNodeError.LiquidityFeeTooHigh = LiquidityFeeTooHigh # type: ignore class InvalidBlindedPaths(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidBlindedPaths({})".format(repr(str(self))) + return "NodeError.InvalidBlindedPaths({})".format(str(self)) _UniffiTempNodeError.InvalidBlindedPaths = InvalidBlindedPaths # type: ignore class AsyncPaymentServicesDisabled(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AsyncPaymentServicesDisabled({})".format(repr(str(self))) + return "NodeError.AsyncPaymentServicesDisabled({})".format(str(self)) _UniffiTempNodeError.AsyncPaymentServicesDisabled = AsyncPaymentServicesDisabled # type: ignore class CannotRbfFundingTransaction(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.CannotRbfFundingTransaction({})".format(repr(str(self))) + return "NodeError.CannotRbfFundingTransaction({})".format(str(self)) _UniffiTempNodeError.CannotRbfFundingTransaction = CannotRbfFundingTransaction # type: ignore class TransactionNotFound(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TransactionNotFound({})".format(repr(str(self))) + return "NodeError.TransactionNotFound({})".format(str(self)) _UniffiTempNodeError.TransactionNotFound = TransactionNotFound # type: ignore class TransactionAlreadyConfirmed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.TransactionAlreadyConfirmed({})".format(repr(str(self))) + return "NodeError.TransactionAlreadyConfirmed({})".format(str(self)) _UniffiTempNodeError.TransactionAlreadyConfirmed = TransactionAlreadyConfirmed # type: ignore class NoSpendableOutputs(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.NoSpendableOutputs({})".format(repr(str(self))) + return "NodeError.NoSpendableOutputs({})".format(str(self)) _UniffiTempNodeError.NoSpendableOutputs = NoSpendableOutputs # type: ignore class CoinSelectionFailed(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.CoinSelectionFailed({})".format(repr(str(self))) + return "NodeError.CoinSelectionFailed({})".format(str(self)) _UniffiTempNodeError.CoinSelectionFailed = CoinSelectionFailed # type: ignore class InvalidMnemonic(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidMnemonic({})".format(repr(str(self))) + return "NodeError.InvalidMnemonic({})".format(str(self)) _UniffiTempNodeError.InvalidMnemonic = InvalidMnemonic # type: ignore class BackgroundSyncNotEnabled(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.BackgroundSyncNotEnabled({})".format(repr(str(self))) + return "NodeError.BackgroundSyncNotEnabled({})".format(str(self)) _UniffiTempNodeError.BackgroundSyncNotEnabled = BackgroundSyncNotEnabled # type: ignore class AddressTypeAlreadyMonitored(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AddressTypeAlreadyMonitored({})".format(repr(str(self))) + return "NodeError.AddressTypeAlreadyMonitored({})".format(str(self)) _UniffiTempNodeError.AddressTypeAlreadyMonitored = AddressTypeAlreadyMonitored # type: ignore class AddressTypeIsPrimary(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AddressTypeIsPrimary({})".format(repr(str(self))) + return "NodeError.AddressTypeIsPrimary({})".format(str(self)) _UniffiTempNodeError.AddressTypeIsPrimary = AddressTypeIsPrimary # type: ignore class AddressTypeNotMonitored(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.AddressTypeNotMonitored({})".format(repr(str(self))) + return "NodeError.AddressTypeNotMonitored({})".format(str(self)) _UniffiTempNodeError.AddressTypeNotMonitored = AddressTypeNotMonitored # type: ignore class OnchainWalletAccountNotRegistered(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.OnchainWalletAccountNotRegistered({})".format(repr(str(self))) + return "NodeError.OnchainWalletAccountNotRegistered({})".format(str(self)) _UniffiTempNodeError.OnchainWalletAccountNotRegistered = OnchainWalletAccountNotRegistered # type: ignore class InvalidSeedBytes(_UniffiTempNodeError): + def __init__(self): + pass def __repr__(self): - return "NodeError.InvalidSeedBytes({})".format(repr(str(self))) + return "NodeError.InvalidSeedBytes({})".format(str(self)) _UniffiTempNodeError.InvalidSeedBytes = InvalidSeedBytes # type: ignore class OnchainTxBroadcastRejected(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid def __repr__(self): - return "NodeError.OnchainTxBroadcastRejected({})".format(repr(str(self))) + return "NodeError.OnchainTxBroadcastRejected({})".format(str(self)) _UniffiTempNodeError.OnchainTxBroadcastRejected = OnchainTxBroadcastRejected # type: ignore class OnchainTxBroadcastFailed(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid def __repr__(self): - return "NodeError.OnchainTxBroadcastFailed({})".format(repr(str(self))) + return "NodeError.OnchainTxBroadcastFailed({})".format(str(self)) _UniffiTempNodeError.OnchainTxBroadcastFailed = OnchainTxBroadcastFailed # type: ignore class OnchainTxBroadcastTimeout(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid def __repr__(self): - return "NodeError.OnchainTxBroadcastTimeout({})".format(repr(str(self))) + return "NodeError.OnchainTxBroadcastTimeout({})".format(str(self)) _UniffiTempNodeError.OnchainTxBroadcastTimeout = OnchainTxBroadcastTimeout # type: ignore + class OnchainTxBroadcastNotDispatched(_UniffiTempNodeError): + def __init__(self, txid): + super().__init__(", ".join([ + "txid={!r}".format(txid), + ])) + self.txid = txid + + def __repr__(self): + return "NodeError.OnchainTxBroadcastNotDispatched({})".format(str(self)) + _UniffiTempNodeError.OnchainTxBroadcastNotDispatched = OnchainTxBroadcastNotDispatched # type: ignore NodeError = _UniffiTempNodeError # type: ignore del _UniffiTempNodeError @@ -13546,287 +13777,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( - _UniffiConverterString.read(buf), + _UniffiConverterTypeTxid.read(buf), ) if variant == 70: return NodeError.OnchainTxBroadcastFailed( - _UniffiConverterString.read(buf), + _UniffiConverterTypeTxid.read(buf), ) if variant == 71: return NodeError.OnchainTxBroadcastTimeout( - _UniffiConverterString.read(buf), + _UniffiConverterTypeTxid.read(buf), + ) + if variant == 72: + return NodeError.OnchainTxBroadcastNotDispatched( + _UniffiConverterTypeTxid.read(buf), ) raise InternalError("Raw enum value doesn't match any cases") @@ -13969,10 +14136,16 @@ def check_lower(value): 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 @@ -14115,10 +14288,16 @@ def write(value, buf): 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) diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index c1be1a9091..e68e35a81e 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3355,6 +3355,8 @@ public func FfiConverterTypeOffer_lower(_ value: Offer) -> UnsafeMutableRawPoint } public protocol OnchainPaymentProtocol: AnyObject { + func abandonPendingBroadcast(txid: Txid) throws + func accelerateByCpfp(txid: Txid, feeRate: FeeRate?, destinationAddress: Address?) throws -> Txid func addressInfoForAccountAtIndex(addressType: AddressType, accountIndex: UInt32, keychain: KeychainKind, index: UInt32) throws -> AddressInfo @@ -3373,6 +3375,8 @@ public protocol OnchainPaymentProtocol: AnyObject { func calculateTotalFee(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> UInt64 + func listPendingBroadcasts() throws -> [Txid] + func listSpendableOutputs() throws -> [SpendableUtxo] func newAddress() throws -> Address @@ -3387,6 +3391,8 @@ public protocol OnchainPaymentProtocol: AnyObject { func newAddressInfoForType(addressType: AddressType) throws -> AddressInfo + func rebroadcastTransaction(txid: Txid) throws -> Txid + func revealReceiveAddressesTo(addressType: AddressType, index: UInt32) throws func revealReceiveAddressesToAccount(addressType: AddressType, accountIndex: UInt32, index: UInt32) throws @@ -3447,6 +3453,13 @@ open class OnchainPayment: try! rustCall { uniffi_ldk_node_fn_free_onchainpayment(pointer, $0) } } + open func abandonPendingBroadcast(txid: Txid) throws { + try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_abandon_pending_broadcast(self.uniffiClonePointer(), + FfiConverterTypeTxid.lower(txid), $0) + } + } + open func accelerateByCpfp(txid: Txid, feeRate: FeeRate?, destinationAddress: Address?) throws -> Txid { return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_accelerate_by_cpfp(self.uniffiClonePointer(), @@ -3531,6 +3544,12 @@ open class OnchainPayment: }) } + open func listPendingBroadcasts() throws -> [Txid] { + return try FfiConverterSequenceTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts(self.uniffiClonePointer(), $0) + }) + } + open func listSpendableOutputs() throws -> [SpendableUtxo] { return try FfiConverterSequenceTypeSpendableUtxo.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_list_spendable_outputs(self.uniffiClonePointer(), $0) @@ -3579,6 +3598,13 @@ open class OnchainPayment: }) } + open func rebroadcastTransaction(txid: Txid) throws -> Txid { + return try FfiConverterTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_onchainpayment_rebroadcast_transaction(self.uniffiClonePointer(), + FfiConverterTypeTxid.lower(txid), $0) + }) + } + open func revealReceiveAddressesTo(addressType: AddressType, index: UInt32) throws { try rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_reveal_receive_addresses_to(self.uniffiClonePointer(), @@ -9178,147 +9204,78 @@ public func FfiConverterTypeNetwork_lower(_ value: Network) -> RustBuffer { extension Network: Equatable, Hashable {} public enum NodeError { - case AlreadyRunning(message: String) - - case NotRunning(message: String) - - case OnchainTxCreationFailed(message: String) - - case ConnectionFailed(message: String) - - case InvoiceCreationFailed(message: String) - - case InvoiceRequestCreationFailed(message: String) - - case OfferCreationFailed(message: String) - - case RefundCreationFailed(message: String) - - case PaymentSendingFailed(message: String) - - case InvalidCustomTlvs(message: String) - - case ProbeSendingFailed(message: String) - - case RouteNotFound(message: String) - - case ChannelCreationFailed(message: String) - - case ChannelClosingFailed(message: String) - - case ChannelSplicingFailed(message: String) - - case ChannelConfigUpdateFailed(message: String) - - case PersistenceFailed(message: String) - - case FeerateEstimationUpdateFailed(message: String) - - case FeerateEstimationUpdateTimeout(message: String) - - case WalletOperationFailed(message: String) - - case WalletOperationTimeout(message: String) - - case OnchainTxSigningFailed(message: String) - - case TxSyncFailed(message: String) - - case TxSyncTimeout(message: String) - - case GossipUpdateFailed(message: String) - - case GossipUpdateTimeout(message: String) - - case LiquidityRequestFailed(message: String) - - case UriParameterParsingFailed(message: String) - - case InvalidAddress(message: String) - - case InvalidSocketAddress(message: String) - - case InvalidPublicKey(message: String) - - case InvalidSecretKey(message: String) - - case InvalidOfferId(message: String) - - case InvalidNodeId(message: String) - - case InvalidPaymentId(message: String) - - case InvalidPaymentHash(message: String) - - case InvalidPaymentPreimage(message: String) - - case InvalidPaymentSecret(message: String) - - case InvalidAmount(message: String) - - case InvalidInvoice(message: String) - - case InvalidOffer(message: String) - - case InvalidRefund(message: String) - - case InvalidChannelId(message: String) - - case InvalidNetwork(message: String) - - case InvalidUri(message: String) - - case InvalidQuantity(message: String) - - case InvalidNodeAlias(message: String) - - case InvalidDateTime(message: String) - - case InvalidFeeRate(message: String) - - case DuplicatePayment(message: String) - - case UnsupportedCurrency(message: String) - - case InsufficientFunds(message: String) - - case LiquiditySourceUnavailable(message: String) - - case LiquidityFeeTooHigh(message: String) - - case InvalidBlindedPaths(message: String) - - case AsyncPaymentServicesDisabled(message: String) - - case CannotRbfFundingTransaction(message: String) - - case TransactionNotFound(message: String) - - case TransactionAlreadyConfirmed(message: String) - - case NoSpendableOutputs(message: String) - - case CoinSelectionFailed(message: String) - - case InvalidMnemonic(message: String) - - case BackgroundSyncNotEnabled(message: String) - - case AddressTypeAlreadyMonitored(message: String) - - case AddressTypeIsPrimary(message: String) - - case AddressTypeNotMonitored(message: String) - - case OnchainWalletAccountNotRegistered(message: String) - - case InvalidSeedBytes(message: String) - - case OnchainTxBroadcastRejected(message: String) - - case OnchainTxBroadcastFailed(message: String) - - case OnchainTxBroadcastTimeout(message: String) + case AlreadyRunning + case NotRunning + case OnchainTxCreationFailed + case ConnectionFailed + case InvoiceCreationFailed + case InvoiceRequestCreationFailed + case OfferCreationFailed + case RefundCreationFailed + case PaymentSendingFailed + case InvalidCustomTlvs + case ProbeSendingFailed + case RouteNotFound + case ChannelCreationFailed + case ChannelClosingFailed + case ChannelSplicingFailed + case ChannelConfigUpdateFailed + case PersistenceFailed + case FeerateEstimationUpdateFailed + case FeerateEstimationUpdateTimeout + case WalletOperationFailed + case WalletOperationTimeout + case OnchainTxSigningFailed + case TxSyncFailed + case TxSyncTimeout + case GossipUpdateFailed + case GossipUpdateTimeout + case LiquidityRequestFailed + case UriParameterParsingFailed + case InvalidAddress + case InvalidSocketAddress + case InvalidPublicKey + case InvalidSecretKey + case InvalidOfferId + case InvalidNodeId + case InvalidPaymentId + case InvalidPaymentHash + case InvalidPaymentPreimage + case InvalidPaymentSecret + case InvalidAmount + case InvalidInvoice + case InvalidOffer + case InvalidRefund + case InvalidChannelId + case InvalidNetwork + case InvalidUri + case InvalidQuantity + case InvalidNodeAlias + case InvalidDateTime + case InvalidFeeRate + case DuplicatePayment + case UnsupportedCurrency + case InsufficientFunds + case LiquiditySourceUnavailable + case LiquidityFeeTooHigh + case InvalidBlindedPaths + case AsyncPaymentServicesDisabled + case CannotRbfFundingTransaction + case TransactionNotFound + case TransactionAlreadyConfirmed + case NoSpendableOutputs + case CoinSelectionFailed + case InvalidMnemonic + case BackgroundSyncNotEnabled + case AddressTypeAlreadyMonitored + case AddressTypeIsPrimary + case AddressTypeNotMonitored + case OnchainWalletAccountNotRegistered + case InvalidSeedBytes + case OnchainTxBroadcastRejected(txid: Txid) + case OnchainTxBroadcastFailed(txid: Txid) + case OnchainTxBroadcastTimeout(txid: Txid) + case OnchainTxBroadcastNotDispatched(txid: Txid) } #if swift(>=5.8) @@ -9330,438 +9287,311 @@ public struct FfiConverterTypeNodeError: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NodeError { let variant: Int32 = try readInt(&buf) switch variant { - case 1: return try .AlreadyRunning( - message: FfiConverterString.read(from: &buf) - ) - - case 2: return try .NotRunning( - message: FfiConverterString.read(from: &buf) - ) - - case 3: return try .OnchainTxCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 4: return try .ConnectionFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 5: return try .InvoiceCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 6: return try .InvoiceRequestCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 7: return try .OfferCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 8: return try .RefundCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 9: return try .PaymentSendingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 10: return try .InvalidCustomTlvs( - message: FfiConverterString.read(from: &buf) - ) - - case 11: return try .ProbeSendingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 12: return try .RouteNotFound( - message: FfiConverterString.read(from: &buf) - ) - - case 13: return try .ChannelCreationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 14: return try .ChannelClosingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 15: return try .ChannelSplicingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 16: return try .ChannelConfigUpdateFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 17: return try .PersistenceFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 18: return try .FeerateEstimationUpdateFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 19: return try .FeerateEstimationUpdateTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 20: return try .WalletOperationFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 21: return try .WalletOperationTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 22: return try .OnchainTxSigningFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 23: return try .TxSyncFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 24: return try .TxSyncTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 25: return try .GossipUpdateFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 26: return try .GossipUpdateTimeout( - message: FfiConverterString.read(from: &buf) - ) - - case 27: return try .LiquidityRequestFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 28: return try .UriParameterParsingFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 29: return try .InvalidAddress( - message: FfiConverterString.read(from: &buf) - ) - - case 30: return try .InvalidSocketAddress( - message: FfiConverterString.read(from: &buf) - ) - - case 31: return try .InvalidPublicKey( - message: FfiConverterString.read(from: &buf) - ) - - case 32: return try .InvalidSecretKey( - message: FfiConverterString.read(from: &buf) - ) - - case 33: return try .InvalidOfferId( - message: FfiConverterString.read(from: &buf) - ) - - case 34: return try .InvalidNodeId( - message: FfiConverterString.read(from: &buf) - ) - - case 35: return try .InvalidPaymentId( - message: FfiConverterString.read(from: &buf) - ) - - case 36: return try .InvalidPaymentHash( - message: FfiConverterString.read(from: &buf) - ) - - case 37: return try .InvalidPaymentPreimage( - message: FfiConverterString.read(from: &buf) - ) - - case 38: return try .InvalidPaymentSecret( - message: FfiConverterString.read(from: &buf) - ) - - case 39: return try .InvalidAmount( - message: FfiConverterString.read(from: &buf) - ) - - case 40: return try .InvalidInvoice( - message: FfiConverterString.read(from: &buf) - ) - - case 41: return try .InvalidOffer( - message: FfiConverterString.read(from: &buf) - ) - - case 42: return try .InvalidRefund( - message: FfiConverterString.read(from: &buf) - ) - - case 43: return try .InvalidChannelId( - message: FfiConverterString.read(from: &buf) - ) - - case 44: return try .InvalidNetwork( - message: FfiConverterString.read(from: &buf) - ) - - case 45: return try .InvalidUri( - message: FfiConverterString.read(from: &buf) - ) - - case 46: return try .InvalidQuantity( - message: FfiConverterString.read(from: &buf) - ) - - case 47: return try .InvalidNodeAlias( - message: FfiConverterString.read(from: &buf) - ) - - case 48: return try .InvalidDateTime( - message: FfiConverterString.read(from: &buf) - ) - - case 49: return try .InvalidFeeRate( - message: FfiConverterString.read(from: &buf) - ) - - case 50: return try .DuplicatePayment( - message: FfiConverterString.read(from: &buf) - ) - - case 51: return try .UnsupportedCurrency( - message: FfiConverterString.read(from: &buf) - ) - - case 52: return try .InsufficientFunds( - message: FfiConverterString.read(from: &buf) - ) - - case 53: return try .LiquiditySourceUnavailable( - message: FfiConverterString.read(from: &buf) - ) - - case 54: return try .LiquidityFeeTooHigh( - message: FfiConverterString.read(from: &buf) - ) - - case 55: return try .InvalidBlindedPaths( - message: FfiConverterString.read(from: &buf) - ) - - case 56: return try .AsyncPaymentServicesDisabled( - message: FfiConverterString.read(from: &buf) - ) - - case 57: return try .CannotRbfFundingTransaction( - message: FfiConverterString.read(from: &buf) - ) - - case 58: return try .TransactionNotFound( - message: FfiConverterString.read(from: &buf) - ) - - case 59: return try .TransactionAlreadyConfirmed( - message: FfiConverterString.read(from: &buf) - ) - - case 60: return try .NoSpendableOutputs( - message: FfiConverterString.read(from: &buf) - ) - - case 61: return try .CoinSelectionFailed( - message: FfiConverterString.read(from: &buf) - ) - - case 62: return try .InvalidMnemonic( - message: FfiConverterString.read(from: &buf) - ) - - case 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 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( - message: FfiConverterString.read(from: &buf) + txid: FfiConverterTypeTxid.read(from: &buf) ) - case 70: return try .OnchainTxBroadcastFailed( - message: FfiConverterString.read(from: &buf) + txid: FfiConverterTypeTxid.read(from: &buf) ) - case 71: return try .OnchainTxBroadcastTimeout( - message: FfiConverterString.read(from: &buf) + txid: FfiConverterTypeTxid.read(from: &buf) + ) + case 72: return try .OnchainTxBroadcastNotDispatched( + txid: FfiConverterTypeTxid.read(from: &buf) ) - default: throw UniffiInternalError.unexpectedEnumCase } } public static func write(_ value: NodeError, into buf: inout [UInt8]) { switch value { - case .AlreadyRunning(_ /* message is ignored*/ ): + case .AlreadyRunning: writeInt(&buf, Int32(1)) - case .NotRunning(_ /* message is ignored*/ ): + + case .NotRunning: writeInt(&buf, Int32(2)) - case .OnchainTxCreationFailed(_ /* message is ignored*/ ): + + case .OnchainTxCreationFailed: writeInt(&buf, Int32(3)) - case .ConnectionFailed(_ /* message is ignored*/ ): + + case .ConnectionFailed: writeInt(&buf, Int32(4)) - case .InvoiceCreationFailed(_ /* message is ignored*/ ): + + case .InvoiceCreationFailed: writeInt(&buf, Int32(5)) - case .InvoiceRequestCreationFailed(_ /* message is ignored*/ ): + + case .InvoiceRequestCreationFailed: writeInt(&buf, Int32(6)) - case .OfferCreationFailed(_ /* message is ignored*/ ): + + case .OfferCreationFailed: writeInt(&buf, Int32(7)) - case .RefundCreationFailed(_ /* message is ignored*/ ): + + case .RefundCreationFailed: writeInt(&buf, Int32(8)) - case .PaymentSendingFailed(_ /* message is ignored*/ ): + + case .PaymentSendingFailed: writeInt(&buf, Int32(9)) - case .InvalidCustomTlvs(_ /* message is ignored*/ ): + + case .InvalidCustomTlvs: writeInt(&buf, Int32(10)) - case .ProbeSendingFailed(_ /* message is ignored*/ ): + + case .ProbeSendingFailed: writeInt(&buf, Int32(11)) - case .RouteNotFound(_ /* message is ignored*/ ): + + case .RouteNotFound: writeInt(&buf, Int32(12)) - case .ChannelCreationFailed(_ /* message is ignored*/ ): + + case .ChannelCreationFailed: writeInt(&buf, Int32(13)) - case .ChannelClosingFailed(_ /* message is ignored*/ ): + + case .ChannelClosingFailed: writeInt(&buf, Int32(14)) - case .ChannelSplicingFailed(_ /* message is ignored*/ ): + + case .ChannelSplicingFailed: writeInt(&buf, Int32(15)) - case .ChannelConfigUpdateFailed(_ /* message is ignored*/ ): + + case .ChannelConfigUpdateFailed: writeInt(&buf, Int32(16)) - case .PersistenceFailed(_ /* message is ignored*/ ): + + case .PersistenceFailed: writeInt(&buf, Int32(17)) - case .FeerateEstimationUpdateFailed(_ /* message is ignored*/ ): + + case .FeerateEstimationUpdateFailed: writeInt(&buf, Int32(18)) - case .FeerateEstimationUpdateTimeout(_ /* message is ignored*/ ): + + case .FeerateEstimationUpdateTimeout: writeInt(&buf, Int32(19)) - case .WalletOperationFailed(_ /* message is ignored*/ ): + + case .WalletOperationFailed: writeInt(&buf, Int32(20)) - case .WalletOperationTimeout(_ /* message is ignored*/ ): + + case .WalletOperationTimeout: writeInt(&buf, Int32(21)) - case .OnchainTxSigningFailed(_ /* message is ignored*/ ): + + case .OnchainTxSigningFailed: writeInt(&buf, Int32(22)) - case .TxSyncFailed(_ /* message is ignored*/ ): + + case .TxSyncFailed: writeInt(&buf, Int32(23)) - case .TxSyncTimeout(_ /* message is ignored*/ ): + + case .TxSyncTimeout: writeInt(&buf, Int32(24)) - case .GossipUpdateFailed(_ /* message is ignored*/ ): + + case .GossipUpdateFailed: writeInt(&buf, Int32(25)) - case .GossipUpdateTimeout(_ /* message is ignored*/ ): + + case .GossipUpdateTimeout: writeInt(&buf, Int32(26)) - case .LiquidityRequestFailed(_ /* message is ignored*/ ): + + case .LiquidityRequestFailed: writeInt(&buf, Int32(27)) - case .UriParameterParsingFailed(_ /* message is ignored*/ ): + + case .UriParameterParsingFailed: writeInt(&buf, Int32(28)) - case .InvalidAddress(_ /* message is ignored*/ ): + + case .InvalidAddress: writeInt(&buf, Int32(29)) - case .InvalidSocketAddress(_ /* message is ignored*/ ): + + case .InvalidSocketAddress: writeInt(&buf, Int32(30)) - case .InvalidPublicKey(_ /* message is ignored*/ ): + + case .InvalidPublicKey: writeInt(&buf, Int32(31)) - case .InvalidSecretKey(_ /* message is ignored*/ ): + + case .InvalidSecretKey: writeInt(&buf, Int32(32)) - case .InvalidOfferId(_ /* message is ignored*/ ): + + case .InvalidOfferId: writeInt(&buf, Int32(33)) - case .InvalidNodeId(_ /* message is ignored*/ ): + + case .InvalidNodeId: writeInt(&buf, Int32(34)) - case .InvalidPaymentId(_ /* message is ignored*/ ): + + case .InvalidPaymentId: writeInt(&buf, Int32(35)) - case .InvalidPaymentHash(_ /* message is ignored*/ ): + + case .InvalidPaymentHash: writeInt(&buf, Int32(36)) - case .InvalidPaymentPreimage(_ /* message is ignored*/ ): + + case .InvalidPaymentPreimage: writeInt(&buf, Int32(37)) - case .InvalidPaymentSecret(_ /* message is ignored*/ ): + + case .InvalidPaymentSecret: writeInt(&buf, Int32(38)) - case .InvalidAmount(_ /* message is ignored*/ ): + + case .InvalidAmount: writeInt(&buf, Int32(39)) - case .InvalidInvoice(_ /* message is ignored*/ ): + + case .InvalidInvoice: writeInt(&buf, Int32(40)) - case .InvalidOffer(_ /* message is ignored*/ ): + + case .InvalidOffer: writeInt(&buf, Int32(41)) - case .InvalidRefund(_ /* message is ignored*/ ): + + case .InvalidRefund: writeInt(&buf, Int32(42)) - case .InvalidChannelId(_ /* message is ignored*/ ): + + case .InvalidChannelId: writeInt(&buf, Int32(43)) - case .InvalidNetwork(_ /* message is ignored*/ ): + + case .InvalidNetwork: writeInt(&buf, Int32(44)) - case .InvalidUri(_ /* message is ignored*/ ): + + case .InvalidUri: writeInt(&buf, Int32(45)) - case .InvalidQuantity(_ /* message is ignored*/ ): + + case .InvalidQuantity: writeInt(&buf, Int32(46)) - case .InvalidNodeAlias(_ /* message is ignored*/ ): + + case .InvalidNodeAlias: writeInt(&buf, Int32(47)) - case .InvalidDateTime(_ /* message is ignored*/ ): + + case .InvalidDateTime: writeInt(&buf, Int32(48)) - case .InvalidFeeRate(_ /* message is ignored*/ ): + + case .InvalidFeeRate: writeInt(&buf, Int32(49)) - case .DuplicatePayment(_ /* message is ignored*/ ): + + case .DuplicatePayment: writeInt(&buf, Int32(50)) - case .UnsupportedCurrency(_ /* message is ignored*/ ): + + case .UnsupportedCurrency: writeInt(&buf, Int32(51)) - case .InsufficientFunds(_ /* message is ignored*/ ): + + case .InsufficientFunds: writeInt(&buf, Int32(52)) - case .LiquiditySourceUnavailable(_ /* message is ignored*/ ): + + case .LiquiditySourceUnavailable: writeInt(&buf, Int32(53)) - case .LiquidityFeeTooHigh(_ /* message is ignored*/ ): + + case .LiquidityFeeTooHigh: writeInt(&buf, Int32(54)) - case .InvalidBlindedPaths(_ /* message is ignored*/ ): + + case .InvalidBlindedPaths: writeInt(&buf, Int32(55)) - case .AsyncPaymentServicesDisabled(_ /* message is ignored*/ ): + + case .AsyncPaymentServicesDisabled: writeInt(&buf, Int32(56)) - case .CannotRbfFundingTransaction(_ /* message is ignored*/ ): + + case .CannotRbfFundingTransaction: writeInt(&buf, Int32(57)) - case .TransactionNotFound(_ /* message is ignored*/ ): + + case .TransactionNotFound: writeInt(&buf, Int32(58)) - case .TransactionAlreadyConfirmed(_ /* message is ignored*/ ): + + case .TransactionAlreadyConfirmed: writeInt(&buf, Int32(59)) - case .NoSpendableOutputs(_ /* message is ignored*/ ): + + case .NoSpendableOutputs: writeInt(&buf, Int32(60)) - case .CoinSelectionFailed(_ /* message is ignored*/ ): + + case .CoinSelectionFailed: writeInt(&buf, Int32(61)) - case .InvalidMnemonic(_ /* message is ignored*/ ): + + case .InvalidMnemonic: writeInt(&buf, Int32(62)) - case .BackgroundSyncNotEnabled(_ /* message is ignored*/ ): + + case .BackgroundSyncNotEnabled: writeInt(&buf, Int32(63)) - case .AddressTypeAlreadyMonitored(_ /* message is ignored*/ ): + + case .AddressTypeAlreadyMonitored: writeInt(&buf, Int32(64)) - case .AddressTypeIsPrimary(_ /* message is ignored*/ ): + + case .AddressTypeIsPrimary: writeInt(&buf, Int32(65)) - case .AddressTypeNotMonitored(_ /* message is ignored*/ ): + + case .AddressTypeNotMonitored: writeInt(&buf, Int32(66)) - case .OnchainWalletAccountNotRegistered(_ /* message is ignored*/ ): + + case .OnchainWalletAccountNotRegistered: writeInt(&buf, Int32(67)) - case .InvalidSeedBytes(_ /* message is ignored*/ ): + + case .InvalidSeedBytes: writeInt(&buf, Int32(68)) - case .OnchainTxBroadcastRejected(_ /* message is ignored*/ ): + + case let .OnchainTxBroadcastRejected(txid): writeInt(&buf, Int32(69)) - case .OnchainTxBroadcastFailed(_ /* message is ignored*/ ): + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .OnchainTxBroadcastFailed(txid): writeInt(&buf, Int32(70)) - case .OnchainTxBroadcastTimeout(_ /* message is ignored*/ ): + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .OnchainTxBroadcastTimeout(txid): writeInt(&buf, Int32(71)) + FfiConverterTypeTxid.write(txid, into: &buf) + + case let .OnchainTxBroadcastNotDispatched(txid): + writeInt(&buf, Int32(72)) + FfiConverterTypeTxid.write(txid, into: &buf) } } } @@ -13587,6 +13417,9 @@ private var initializationResult: InitializationResult = { if uniffi_ldk_node_checksum_method_offer_supports_chain() != 2135 { return InitializationResult.apiChecksumMismatch } + if uniffi_ldk_node_checksum_method_onchainpayment_abandon_pending_broadcast() != 686 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_accelerate_by_cpfp() != 31954 { return InitializationResult.apiChecksumMismatch } @@ -13614,6 +13447,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() != 22129 { + return InitializationResult.apiChecksumMismatch + } if uniffi_ldk_node_checksum_method_onchainpayment_list_spendable_outputs() != 19144 { return InitializationResult.apiChecksumMismatch } @@ -13635,6 +13471,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 } From 935f64d85ee28869a40771392b8a5c801533feda Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 14:15:15 +0200 Subject: [PATCH 20/30] Update eviction test for eager send indexing --- tests/integration_tests_rust.rs | 36 ++++++++++++--------------------- 1 file changed, 13 insertions(+), 23 deletions(-) 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); From 25cab2a7f7403eba4d721b8bb70e2666ab90f945 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 14:53:09 +0200 Subject: [PATCH 21/30] Make transaction abandonment timestamp safe --- crates/bdk-wallet-aggregate/src/lib.rs | 80 ++++++++++++++++++++++++-- src/wallet/mod.rs | 19 ++---- 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs index 388964a87e..5d3a15251b 100644 --- a/crates/bdk-wallet-aggregate/src/lib.rs +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -26,6 +26,7 @@ use std::fmt::Debug; use std::hash::Hash; use std::ops::{Deref, DerefMut}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_chain::{ChainPosition, ConfirmationBlockTime}; @@ -628,15 +629,17 @@ where /// 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, last_seen: u64) -> Result<(), Error> { - self.abandon_txs(std::slice::from_ref(tx), last_seen) + 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], last_seen: u64) -> Result<(), Error> { + pub fn abandon_txs(&mut self, txs: &[Transaction]) -> Result<(), Error> { + let txids = txs.iter().map(Transaction::compute_txid).collect::>(); + let last_seen = self.next_transaction_update_timestamp(&txids)?; let evicted_txs = txs.iter().map(|tx| (tx.compute_txid(), last_seen)).collect::>(); for (key, wallet) in self.wallets.iter_mut() { wallet.apply_evicted_txs(evicted_txs.iter().copied()); @@ -652,6 +655,28 @@ where 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 @@ -1905,7 +1930,7 @@ mod tests { assert_eq!(aggregate.find_tx(txid), Some(tx.clone())); assert!(aggregate.unconfirmed_txids().contains(&txid)); - aggregate.abandon_tx(&tx, 1).unwrap(); + aggregate.abandon_tx(&tx).unwrap(); assert_eq!(aggregate.find_tx(txid), None); assert!(!aggregate.unconfirmed_txids().contains(&txid)); assert!(aggregate @@ -1914,6 +1939,53 @@ mod tests { .any(|output| output.outpoint == tx.input[0].previous_output)); } + #[test] + fn abandonment_is_newer_than_the_latest_transaction_observation() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + let latest_observation = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs().saturating_add(60); + + aggregate.apply_mempool_txs(vec![(tx.clone(), latest_observation)], Vec::new()).unwrap(); + aggregate.abandon_tx(&tx).unwrap(); + + let latest_eviction = aggregate + .wallets() + .values() + .filter_map(|wallet| wallet.tx_graph().get_last_evicted(txid)) + .max() + .unwrap(); + assert_eq!(latest_eviction, latest_observation + 1); + assert_eq!(aggregate.find_tx(txid), None); + } + + #[test] + fn abandonment_fails_closed_when_timestamp_cannot_advance() { + let mut persister = NoopPersister; + let wallet = create_funded_wallet(&mut persister, Amount::from_sat(100_000)); + let mut aggregate = AggregateWallet::new(wallet, persister, 0u8, vec![]); + let tx = aggregate + .build_and_sign_drain( + recipient_script(), + FeeRate::from_sat_per_vb(1).expect("valid fee rate"), + ) + .unwrap(); + let txid = tx.compute_txid(); + + aggregate.apply_mempool_txs(vec![(tx.clone(), u64::MAX)], Vec::new()).unwrap(); + + assert_eq!(aggregate.abandon_tx(&tx), Err(Error::WalletOperationFailed)); + assert_eq!(aggregate.find_tx(txid), Some(tx)); + } + #[test] fn pending_broadcast_reapplication_must_be_newer_than_an_equal_eviction() { let mut persister = NoopPersister; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8791ed7eef..759ed41087 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1243,8 +1243,7 @@ impl Wallet { } let txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); - let abandoned_at = Self::next_broadcast_timestamp(&locked_wallet, &txids)?; - locked_wallet.abandon_txs(&intent.transactions, abandoned_at).map_err(|e| { + locked_wallet.abandon_txs(&intent.transactions).map_err(|e| { log_error!( self.logger, "Failed to abandon pending transaction {}: {}", @@ -1909,18 +1908,10 @@ impl Wallet { let mut locked_wallet = self.inner.lock().unwrap(); let lineage_txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); - let rejected_at = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; - locked_wallet.abandon_txs(std::slice::from_ref(&rejected_tx), rejected_at).map_err( - |e| { - log_error!( - self.logger, - "Failed to reject RBF replacement {}: {}", - rejected_txid, - e - ); - Error::PersistenceFailed - }, - )?; + 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( From 96db49ef573dbeff4044f698bdabbd4067ddc20b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 15:06:19 +0200 Subject: [PATCH 22/30] Refresh bindings after abandonment fix --- Package.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Package.swift b/Package.swift index dea1f95596..83cb93efcd 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let tag = "v0.7.0-rc.67" <<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD +<<<<<<< HEAD let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" >>>>>>> 049b116 (fix: classify broadcast backend results) ======= @@ -26,6 +27,9 @@ let checksum = "61565a56ecf33a1e8634de5e3be7d388f0579565161b1237c56756cc8b6638ce ======= let checksum = "42bf21b752b53c02267abf68ef38ba2327a3be8662e391121ff6febac1509a97" >>>>>>> 262ffd0 (Regenerate UniFFI bindings) +======= +let checksum = "d6ccac3a6a013fbb5313d49faf4d3373b14053ede91923e9a1cd4f3230c6848c" +>>>>>>> 833215c (Refresh bindings after abandonment fix) let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( From f89f90ac5fa4257e5fcd304b78bfa1a1624209cd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 23:50:03 +0200 Subject: [PATCH 23/30] fix: address on-chain broadcast review --- CHANGELOG.md | 5 ++ bindings/ldk_node.udl | 7 +- src/chain/electrum.rs | 81 +++++++------------- src/chain/mod.rs | 22 ++++++ src/ffi/types.rs | 2 +- src/lib.rs | 6 +- src/payment/mod.rs | 2 +- src/payment/onchain.rs | 55 ++++++++++---- src/wallet/mod.rs | 166 +++++++++++++++++++++++++++++++++++------ 9 files changed, 246 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29b8ee0614..8057d027db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ # 0.7.0-rc.67 (Synonym Fork) ## Bug Fixes +<<<<<<< HEAD <<<<<<< HEAD - 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. ======= +======= +- `list_pending_broadcasts` now returns each unresolved spend's complete RBF lineage so callers can + independently reconcile every replacement before `abandon_pending_broadcast`. +>>>>>>> a58ddaa (fix: address on-chain broadcast review) - 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 diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 790920dd35..62d8a205f9 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -389,7 +389,7 @@ interface OnchainPayment { [Throws=NodeError] Txid rebroadcast_transaction([ByRef]Txid txid); [Throws=NodeError] - sequence list_pending_broadcasts(); + sequence list_pending_broadcasts(); [Throws=NodeError] void abandon_pending_broadcast([ByRef]Txid txid); [Throws=NodeError] @@ -411,6 +411,11 @@ enum CoinSelectionAlgorithm { "SingleRandomDraw", }; +dictionary PendingBroadcastInfo { + Txid txid; + sequence lineage; +}; + dictionary SpendableUtxo { OutPoint outpoint; u64 value_sats; diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 3ef971172c..bee520a450 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::{ @@ -849,73 +849,46 @@ impl ElectrumRuntimeClient { let spawn_fut = self.runtime_handle.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); - let timeout_fut = - tokio::time::timeout(Duration::from_secs(TX_BROADCAST_TIMEOUT_SECS), spawn_fut); - - match timeout_fut.await { - Ok(res) => match res { - Ok(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!( + + 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, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) + "Backend returned transaction ID {} for submitted transaction {}", + id, + txid ); - } - result - }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); + }, + (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) ); - Err(TxBroadcastError::Failed) - }, + } + 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::Timeout) + Err(TxBroadcastError::Failed) }, } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 8c719aa16a..48aeb5c4ab 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -565,6 +565,28 @@ where }, } } + + for txid in wallet.take_locally_applied_unconfirmed_txids() { + if !seen_received_txids.insert(txid) { + continue; + } + let details = + get_transaction_details(&txid, wallet, channel_manager).unwrap_or_else(|| { + log_error!(logger, "Transaction {} not found in wallet", txid); + TransactionDetails { amount_sats: 0, inputs: Vec::new(), outputs: Vec::new() } + }); + log_info!( + logger, + "New unconfirmed transaction {} detected in mempool (amount: {} sats)", + txid, + details.amount_sats + ); + let event = Event::OnchainTransactionReceived { txid, details }; + event_queue.add_event(event).await.map_err(|e| { + log_error!(logger, "Failed to push onchain event to queue: {}", e); + e + })?; + } Ok(()) } diff --git a/src/ffi/types.rs b/src/ffi/types.rs index c2d7074f0a..e8cd975d6a 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -59,7 +59,7 @@ pub use crate::payment::store::{ }; pub use crate::payment::QrPaymentResult; #[allow(unused_imports)] -pub use crate::payment::{AddressInfo, KeychainKind}; +pub use crate::payment::{AddressInfo, KeychainKind, PendingBroadcastInfo}; pub use crate::types::SpendableUtxo; use crate::{hex_utils, SocketAddress, UniffiCustomTypeConverter, UserChannelId}; diff --git a/src/lib.rs b/src/lib.rs index dfb2f7836a..e3a2e19f28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,7 +155,7 @@ use liquidity::{LSPS1Liquidity, LiquiditySource}; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; -pub use payment::{AddressInfo, KeychainKind}; +pub use payment::{AddressInfo, KeychainKind, PendingBroadcastInfo}; use payment::{ Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, UnifiedQrPayment, @@ -1116,7 +1116,7 @@ impl Node { OnchainPayment::new( Arc::clone(&self.wallet), Arc::clone(&self.tx_broadcaster), - Arc::clone(&self.runtime), + self.runtime.control(), Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), @@ -1130,7 +1130,7 @@ impl Node { Arc::new(OnchainPayment::new( Arc::clone(&self.wallet), Arc::clone(&self.tx_broadcaster), - Arc::clone(&self.runtime), + self.runtime.control(), Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ba59aea06a..7034615c6c 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -17,7 +17,7 @@ mod unified_qr; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; -pub use onchain::{AddressInfo, KeychainKind, OnchainPayment}; +pub use onchain::{AddressInfo, KeychainKind, OnchainPayment, PendingBroadcastInfo}; pub use spontaneous::SpontaneousPayment; pub use store::{ ConfirmationStatus, LSPFeeLimits, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 452440c7a9..39288bb2a9 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -15,7 +15,7 @@ use crate::config::{AddressType, Config, OnchainWalletAccount}; use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; use crate::logger::{log_error, log_info, LdkLogger, Logger}; -use crate::runtime::Runtime; +use crate::runtime::RuntimeControl; use crate::tx_broadcaster::{ExplicitBroadcastAdmission, TxBroadcastError}; use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; @@ -69,6 +69,20 @@ impl From for bdk_wallet::KeychainKind { } } +/// An explicit on-chain broadcast whose backend acceptance is still unresolved. +/// +/// `txid` is the active transaction that [`OnchainPayment::rebroadcast_transaction`] and +/// [`OnchainPayment::abandon_pending_broadcast`] currently target. `lineage` is the complete +/// replacement history from the original spend through that active transaction and must be +/// independently reconciled as absent before abandonment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingBroadcastInfo { + /// Active transaction ID currently awaiting backend reconciliation. + pub txid: Txid, + /// Complete RBF lineage from the original spend through [`Self::txid`]. + pub lineage: Vec, +} + /// Metadata for an address derived by the on-chain wallet. /// /// The `index` is the BIP32 child index within `keychain`. For receive addresses generated by @@ -101,7 +115,7 @@ impl From for AddressInfo { pub struct OnchainPayment { wallet: Arc, tx_broadcaster: Arc, - runtime: Arc, + runtime: Arc, channel_manager: Arc, config: Arc, is_running: Arc>, @@ -110,7 +124,7 @@ pub struct OnchainPayment { impl OnchainPayment { pub(crate) fn new( - wallet: Arc, tx_broadcaster: Arc, runtime: Arc, + wallet: Arc, tx_broadcaster: Arc, runtime: Arc, channel_manager: Arc, config: Arc, is_running: Arc>, logger: Arc, ) -> Self { @@ -721,23 +735,32 @@ impl OnchainPayment { } } - /// List explicit transaction IDs whose backend acceptance is still unresolved. - /// - /// Entries survive process restart and ordinary mempool eviction. RBF supersession atomically - /// changes the active transaction ID while preserving predecessor transactions for backend - /// reconciliation. 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> { - self.wallet.list_pending_broadcasts() + /// List explicit broadcasts whose backend acceptance is still unresolved. + /// + /// Entries survive process restart and ordinary mempool eviction. Each record exposes the + /// active transaction ID plus the complete RBF lineage required to reconcile before + /// [`Self::abandon_pending_broadcast`]. Callers must not create a new transaction for the same + /// payment intent while an entry remains. RBF supersession atomically changes the active + /// transaction ID while preserving predecessor transactions. An entry is removed only after a + /// conclusive initial rejection/not-dispatched result, successful explicit broadcast, backend + /// observation, or explicit reconciled abandon. + pub fn list_pending_broadcasts(&self) -> Result, Error> { + Ok(self + .wallet + .list_pending_broadcast_infos()? + .into_iter() + .map(|(txid, lineage)| PendingBroadcastInfo { txid, lineage }) + .collect()) } /// Abandon an unresolved explicit broadcast after conclusive external reconciliation. /// - /// Call this only after independently verifying that the transaction and every replacement for - /// the same payment intent are absent from the mempool and chain and will not be rebroadcast by - /// another process. 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. + /// Call this only after independently verifying that the active transaction and every member of + /// its [`PendingBroadcastInfo::lineage`] are absent from the mempool and chain and will not be + /// rebroadcast by another process. Do not create a new spend of the same inputs until that + /// independent reconciliation is complete. This releases their reserved inputs and removes + /// their pending payment records. If the active transaction is later observed, ordinary wallet + /// sync will record it again. /// /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if /// `txid` is not the active transaction returned by [`Self::list_pending_broadcasts`]. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 759ed41087..8bc80be3cd 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -374,17 +374,20 @@ fn observed_broadcast_intents( { return Some((*key, txid, true)); } - intent - .transactions - .iter() - .rev() - .map(|tx| tx.compute_txid()) - .find(|txid| observed_txids.contains(txid)) - .map(|txid| (*key, txid, false)) + let active_txid = intent.active_txid(); + observed_txids.contains(&active_txid).then_some((*key, active_txid, false)) }) .collect() } +fn clamp_external_timestamp(timestamp: u64, now: u64) -> u64 { + if timestamp > u32::MAX as u64 { + now + } else { + timestamp + } +} + fn map_wallet_account_error( wallet_account: OnchainWalletAccount, error: bdk_wallet_aggregate::Error, ) -> Error { @@ -451,6 +454,7 @@ pub(crate) struct Wallet { node_metrics: Arc>, logger: Arc, derived_account_lookahead: u32, + locally_applied_unconfirmed_txids: Mutex>, } impl Wallet { @@ -492,6 +496,7 @@ impl Wallet { node_metrics, logger, derived_account_lookahead, + locally_applied_unconfirmed_txids: Mutex::new(Vec::new()), } } @@ -1174,6 +1179,15 @@ impl Wallet { ) -> 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()?; @@ -1218,6 +1232,7 @@ impl Wallet { log_error!(self.logger, "Failed to index pending transaction {}: {}", txid, e); return Err(Error::OnchainTxBroadcastFailed { txid }); } + self.note_locally_applied_unconfirmed(txid); Ok(()) } @@ -1253,9 +1268,10 @@ impl Wallet { Error::PersistenceFailed })?; drop(locked_wallet); - for intent_txid in txids { + for intent_txid in &txids { self.payment_store.remove(&PaymentId(intent_txid.to_byte_array()))?; } + self.forget_locally_applied_unconfirmed(&txids); self.remove_broadcast_intent(&intent_key) } @@ -1275,31 +1291,65 @@ impl Wallet { let lineage_txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); let mut locked_wallet = self.inner.lock().unwrap(); - let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; - 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 }); + match Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids) { + Ok(last_seen) => { + self.payment_store_update_pending.store(true, Ordering::Release); + if let Err(e) = + locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) + { + log_error!( + self.logger, + "Failed to restore pending transaction {}: {}", + txid, + e + ); + return Err(Error::OnchainTxBroadcastFailed { txid: *txid }); + } + if let Err(e) = self.update_payment_store(&locked_wallet) { + log_error!(self.logger, "Failed to restore payment index for {}: {}", txid, e); + return Err(Error::OnchainTxBroadcastFailed { txid: *txid }); + } + }, + Err(_) if locked_wallet.find_tx(*txid).is_some() => {}, + Err(_) => return Err(Error::OnchainTxBroadcastFailed { txid: *txid }), } Ok(Some(tx)) } /// List transaction IDs whose backend acceptance still requires reconciliation. + #[cfg(test)] pub(crate) fn list_pending_broadcasts(&self) -> Result, Error> { + Ok(self.list_pending_broadcast_infos()?.into_iter().map(|(txid, _)| txid).collect()) + } + + pub(crate) fn list_pending_broadcast_infos(&self) -> Result)>, Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); self.complete_broadcast_transitions()?; Ok(self .read_all_broadcast_intents()? .into_iter() .filter(|(_, intent)| intent.has_pending_transaction()) - .map(|(_, intent)| intent.active_txid()) + .map(|(_, intent)| { + ( + intent.active_txid(), + intent.transactions.iter().map(|tx| tx.compute_txid()).collect(), + ) + }) .collect()) } + pub(crate) fn take_locally_applied_unconfirmed_txids(&self) -> Vec { + std::mem::take(&mut *self.locally_applied_unconfirmed_txids.lock().unwrap()) + } + + fn note_locally_applied_unconfirmed(&self, txid: Txid) { + self.locally_applied_unconfirmed_txids.lock().unwrap().push(txid); + } + + fn forget_locally_applied_unconfirmed(&self, txids: &[Txid]) { + self.locally_applied_unconfirmed_txids.lock().unwrap().retain(|txid| !txids.contains(txid)); + } + /// Resolve an accepted broadcast while retaining multi-hop RBF lineage until confirmation. pub(crate) fn clear_broadcast_intent(&self, txid: &Txid) -> Result<(), Error> { let _intent = self.broadcast_intent_lock.lock().unwrap(); @@ -1368,7 +1418,11 @@ impl Wallet { for intent in intents { let lineage_txids = intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); - let last_seen = Self::next_broadcast_timestamp(locked_wallet, &lineage_txids)?; + 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| { @@ -1398,7 +1452,11 @@ impl Wallet { }) .max() .unwrap_or(0); - now.max(latest_seen).max(latest_evicted).checked_add(1).ok_or(Error::WalletOperationFailed) + 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> { @@ -1858,6 +1916,7 @@ impl Wallet { log_error!(self.logger, "Failed to index RBF replacement {}: {}", new_txid, e); return Err(Error::OnchainTxBroadcastFailed { txid: new_txid }); } + self.note_locally_applied_unconfirmed(new_txid); // Calculate and log the actual fee increase achieved let new_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); @@ -3489,8 +3548,9 @@ impl ChangeDestinationSource for WalletKeysManager { #[cfg(test)] mod tests { use super::{ - additional_input_weight, map_wallet_account_error, validate_derivation_index, - validate_derivation_range, BroadcastIntent, BroadcastIntentState, BIP32_MAX_NORMAL_INDEX, + additional_input_weight, map_wallet_account_error, observed_broadcast_intents, + validate_derivation_index, validate_derivation_range, BroadcastIntent, + BroadcastIntentState, BIP32_MAX_NORMAL_INDEX, LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION, LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION, MAX_ADDRESS_INFO_BATCH_COUNT, }; @@ -3926,16 +3986,74 @@ mod tests { } #[test] - fn observing_an_rbf_predecessor_resolves_the_active_replacement_intent() { + 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 intent = BroadcastIntent::replacement(None, original.clone(), replacement).unwrap(); + let replacement_txid = replacement.compute_txid(); + let intent = + BroadcastIntent::replacement(None, original.clone(), replacement.clone()).unwrap(); node.wallet.write_broadcast_intent(&intent).unwrap(); node.wallet.apply_mempool_txs(vec![(original, 1)], Vec::new()).unwrap(); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![replacement_txid]); + assert_eq!( + node.wallet.list_pending_broadcast_infos().unwrap(), + vec![(replacement_txid, vec![intent.key(), replacement_txid])] + ); + } + + #[test] + fn terminal_last_seen_does_not_persist_max_or_fail_restart() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(Arc::clone(&store)); + let script_pubkey = node.onchain_payment().new_address().unwrap().script_pubkey(); + let mut original = replacement_test_transaction(29); + original.input[0].previous_output.vout = 1; + original.output.push(TxOut { value: Amount::from_sat(10_000), script_pubkey }); + let txid = original.compute_txid(); + node.wallet.prepare_pending_broadcast(&original).unwrap(); + node.wallet + .inner + .lock() + .unwrap() + .apply_mempool_txs(vec![(original.clone(), u64::MAX - 1)], Vec::new()) + .unwrap(); + { + let wallet = node.wallet.inner.lock().unwrap(); + assert_eq!( + super::Wallet::next_broadcast_timestamp(&wallet, &[txid]), + Err(Error::WalletOperationFailed) + ); + } + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + drop(node); + + let restarted = replacement_test_node(store); + assert_eq!(restarted.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert_eq!(restarted.wallet.recover_pending_broadcast(&txid).unwrap(), Some(original)); + } + + #[test] + fn 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()); } From 9489e9f6329a0d1ac2380ed981edfa8d3b424864 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 4 Sep 2026 00:36:28 +0200 Subject: [PATCH 24/30] fix: address remaining broadcast review --- Package.swift | 4 + .../ldknode/ldk_node.android.kt | 53 ++++++++++- .../ldknode/ldk_node.common.kt | 14 ++- bindings/python/src/ldk_node/ldk_node.py | 68 +++++++++++++- bindings/swift/Sources/LDKNode/LDKNode.swift | 94 ++++++++++++++++++- src/chain/mod.rs | 11 ++- src/wallet/mod.rs | 80 +++++++++++++++- 7 files changed, 303 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index 83cb93efcd..2f2d20dda8 100644 --- a/Package.swift +++ b/Package.swift @@ -16,6 +16,7 @@ let tag = "v0.7.0-rc.67" <<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD +<<<<<<< HEAD let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" >>>>>>> 049b116 (fix: classify broadcast backend results) ======= @@ -30,6 +31,9 @@ let checksum = "42bf21b752b53c02267abf68ef38ba2327a3be8662e391121ff6febac1509a97 ======= let checksum = "d6ccac3a6a013fbb5313d49faf4d3373b14053ede91923e9a1cd4f3230c6848c" >>>>>>> 833215c (Refresh bindings after abandonment fix) +======= +let checksum = "f16f94119be8df627fec4d974f911339ccf74c9b6961feec66841e338f5d4683" +>>>>>>> 072d3cd (fix: address remaining broadcast review) 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/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 1da658554e..6ef1884f68 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.android.kt @@ -4149,7 +4149,7 @@ 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() != 22129.toShort()) { + 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()) { @@ -8524,8 +8524,8 @@ open class OnchainPayment: Disposable, OnchainPaymentInterface { } @Throws(NodeException::class) - override fun `listPendingBroadcasts`(): List { - return FfiConverterSequenceTypeTxid.lift(callWithPointer { + override fun `listPendingBroadcasts`(): List { + return FfiConverterSequenceTypePendingBroadcastInfo.lift(callWithPointer { uniffiRustCallWithError(NodeExceptionErrorHandler) { uniffiRustCallStatus -> UniffiLib.INSTANCE.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts( it, @@ -10574,6 +10574,28 @@ object FfiConverterTypePeerDetails: FfiConverterRustBuffer { +object FfiConverterTypePendingBroadcastInfo: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): PendingBroadcastInfo { + return PendingBroadcastInfo( + FfiConverterTypeTxid.read(buf), + FfiConverterSequenceTypeTxid.read(buf), + ) + } + + override fun allocationSize(value: PendingBroadcastInfo) = ( + FfiConverterTypeTxid.allocationSize(value.`txid`) + + FfiConverterSequenceTypeTxid.allocationSize(value.`lineage`) + ) + + override fun write(value: PendingBroadcastInfo, buf: ByteBuffer) { + FfiConverterTypeTxid.write(value.`txid`, buf) + FfiConverterSequenceTypeTxid.write(value.`lineage`, buf) + } +} + + + + object FfiConverterTypeProbeHandle: FfiConverterRustBuffer { override fun read(buf: ByteBuffer): ProbeHandle { return ProbeHandle( @@ -14985,6 +15007,31 @@ object FfiConverterSequenceTypePeerDetails: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypePendingBroadcastInfo.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.sumOf { FfiConverterTypePendingBroadcastInfo.allocationSize(it) } + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypePendingBroadcastInfo.write(it, buf) + } + } +} + + + + object FfiConverterSequenceTypeProbeHandle: FfiConverterRustBuffer> { override fun read(buf: ByteBuffer): List { val len = buf.getInt() diff --git a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt index c86f05bb1b..5c5bada1f2 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -624,7 +624,7 @@ interface OnchainPaymentInterface { fun `calculateTotalFee`(`address`: Address, `amountSats`: kotlin.ULong, `feeRate`: FeeRate?, `utxosToSpend`: List?): kotlin.ULong @Throws(NodeException::class) - fun `listPendingBroadcasts`(): List + fun `listPendingBroadcasts`(): List @Throws(NodeException::class) fun `listSpendableOutputs`(): List @@ -1206,6 +1206,16 @@ data class PeerDetails ( +@kotlinx.serialization.Serializable +data class PendingBroadcastInfo ( + val `txid`: Txid, + val `lineage`: List +) { + companion object +} + + + @kotlinx.serialization.Serializable data class ProbeHandle ( val `paymentHash`: PaymentHash, @@ -2720,6 +2730,8 @@ enum class WordCount { + + diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index 87722764cd..01dc547b43 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -825,7 +825,7 @@ 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() != 22129: + 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") @@ -7174,8 +7174,8 @@ def calculate_total_fee(self, address: "Address",amount_sats: "int",fee_rate: "t - def list_pending_broadcasts(self, ) -> "typing.List[Txid]": - return _UniffiConverterSequenceTypeTxid.lift( + def list_pending_broadcasts(self, ) -> "typing.List[PendingBroadcastInfo]": + return _UniffiConverterSequenceTypePendingBroadcastInfo.lift( _uniffi_rust_call_with_error(_UniffiConverterTypeNodeError,_UniffiLib.uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts,self._uniffi_clone_pointer(),) ) @@ -9832,6 +9832,42 @@ def write(value, buf): _UniffiConverterBool.write(value.is_connected, buf) +class PendingBroadcastInfo: + txid: "Txid" + lineage: "typing.List[Txid]" + def __init__(self, *, txid: "Txid", lineage: "typing.List[Txid]"): + self.txid = txid + self.lineage = lineage + + def __str__(self): + return "PendingBroadcastInfo(txid={}, lineage={})".format(self.txid, self.lineage) + + def __eq__(self, other): + if self.txid != other.txid: + return False + if self.lineage != other.lineage: + return False + return True + +class _UniffiConverterTypePendingBroadcastInfo(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return PendingBroadcastInfo( + txid=_UniffiConverterTypeTxid.read(buf), + lineage=_UniffiConverterSequenceTypeTxid.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterTypeTxid.check_lower(value.txid) + _UniffiConverterSequenceTypeTxid.check_lower(value.lineage) + + @staticmethod + def write(value, buf): + _UniffiConverterTypeTxid.write(value.txid, buf) + _UniffiConverterSequenceTypeTxid.write(value.lineage, buf) + + class ProbeHandle: payment_hash: "PaymentHash" payment_id: "PaymentId" @@ -16933,6 +16969,31 @@ def read(cls, buf): +class _UniffiConverterSequenceTypePendingBroadcastInfo(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + for item in value: + _UniffiConverterTypePendingBroadcastInfo.check_lower(item) + + @classmethod + def write(cls, value, buf): + items = len(value) + buf.write_i32(items) + for item in value: + _UniffiConverterTypePendingBroadcastInfo.write(item, buf) + + @classmethod + def read(cls, buf): + count = buf.read_i32() + if count < 0: + raise InternalError("Unexpected negative sequence length") + + return [ + _UniffiConverterTypePendingBroadcastInfo.read(buf) for i in range(count) + ] + + + class _UniffiConverterSequenceTypeProbeHandle(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -17928,6 +17989,7 @@ def generate_entropy_mnemonic(word_count: "typing.Optional[WordCount]") -> "Mnem "OutPoint", "PaymentDetails", "PeerDetails", + "PendingBroadcastInfo", "ProbeHandle", "RouteHintHop", "RouteParametersConfig", diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index e68e35a81e..481ac2a4e0 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3375,7 +3375,7 @@ public protocol OnchainPaymentProtocol: AnyObject { func calculateTotalFee(address: Address, amountSats: UInt64, feeRate: FeeRate?, utxosToSpend: [SpendableUtxo]?) throws -> UInt64 - func listPendingBroadcasts() throws -> [Txid] + func listPendingBroadcasts() throws -> [PendingBroadcastInfo] func listSpendableOutputs() throws -> [SpendableUtxo] @@ -3544,8 +3544,8 @@ open class OnchainPayment: }) } - open func listPendingBroadcasts() throws -> [Txid] { - return try FfiConverterSequenceTypeTxid.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { + open func listPendingBroadcasts() throws -> [PendingBroadcastInfo] { + return try FfiConverterSequenceTypePendingBroadcastInfo.lift(rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_onchainpayment_list_pending_broadcasts(self.uniffiClonePointer(), $0) }) } @@ -6981,6 +6981,67 @@ public func FfiConverterTypePeerDetails_lower(_ value: PeerDetails) -> RustBuffe return FfiConverterTypePeerDetails.lower(value) } +public struct PendingBroadcastInfo { + public var txid: Txid + public var lineage: [Txid] + + /// Default memberwise initializers are never public by default, so we + /// declare one manually. + public init(txid: Txid, lineage: [Txid]) { + self.txid = txid + self.lineage = lineage + } +} + +extension PendingBroadcastInfo: Equatable, Hashable { + public static func == (lhs: PendingBroadcastInfo, rhs: PendingBroadcastInfo) -> Bool { + if lhs.txid != rhs.txid { + return false + } + if lhs.lineage != rhs.lineage { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(txid) + hasher.combine(lineage) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public struct FfiConverterTypePendingBroadcastInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PendingBroadcastInfo { + return + try PendingBroadcastInfo( + txid: FfiConverterTypeTxid.read(from: &buf), + lineage: FfiConverterSequenceTypeTxid.read(from: &buf) + ) + } + + public static func write(_ value: PendingBroadcastInfo, into buf: inout [UInt8]) { + FfiConverterTypeTxid.write(value.txid, into: &buf) + FfiConverterSequenceTypeTxid.write(value.lineage, into: &buf) + } +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypePendingBroadcastInfo_lift(_ buf: RustBuffer) throws -> PendingBroadcastInfo { + return try FfiConverterTypePendingBroadcastInfo.lift(buf) +} + +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +public func FfiConverterTypePendingBroadcastInfo_lower(_ value: PendingBroadcastInfo) -> RustBuffer { + return FfiConverterTypePendingBroadcastInfo.lower(value) +} + public struct ProbeHandle { public var paymentHash: PaymentHash public var paymentId: PaymentId @@ -11645,6 +11706,31 @@ private struct FfiConverterSequenceTypePeerDetails: FfiConverterRustBuffer { } } +#if swift(>=5.8) + @_documentation(visibility: private) +#endif +private struct FfiConverterSequenceTypePendingBroadcastInfo: FfiConverterRustBuffer { + typealias SwiftType = [PendingBroadcastInfo] + + static func write(_ value: [PendingBroadcastInfo], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePendingBroadcastInfo.write(item, into: &buf) + } + } + + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PendingBroadcastInfo] { + let len: Int32 = try readInt(&buf) + var seq = [PendingBroadcastInfo]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + try seq.append(FfiConverterTypePendingBroadcastInfo.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -13447,7 +13533,7 @@ 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() != 22129 { + 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 { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 48aeb5c4ab..28885d1c6a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -570,11 +570,12 @@ where if !seen_received_txids.insert(txid) { continue; } - let details = - get_transaction_details(&txid, wallet, channel_manager).unwrap_or_else(|| { - log_error!(logger, "Transaction {} not found in wallet", txid); - TransactionDetails { amount_sats: 0, inputs: Vec::new(), outputs: Vec::new() } - }); + if seen_confirmed_txids.contains(&txid) || transaction_confirmations.contains_key(&txid) { + continue; + } + let Some(details) = get_transaction_details(&txid, wallet, channel_manager) else { + continue; + }; log_info!( logger, "New unconfirmed transaction {} detected in mempool (amount: {} sats)", diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8bc80be3cd..f4ff22a803 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1902,11 +1902,7 @@ impl Wallet { original_tx, tx.clone(), )?; - self.write_broadcast_intent(&replacement_intent)?; - - let lineage_txids = - replacement_intent.transactions.iter().map(|tx| tx.compute_txid()).collect::>(); - let last_seen = Self::next_broadcast_timestamp(&locked_wallet, &lineage_txids)?; + let last_seen = self.write_rbf_replacement_intent(&locked_wallet, &replacement_intent)?; self.payment_store_update_pending.store(true, Ordering::Release); if let Err(e) = locked_wallet.apply_mempool_txs(vec![(tx.clone(), last_seen)], Vec::new()) { log_error!(self.logger, "Failed to reserve RBF replacement {}: {}", new_txid, e); @@ -1940,6 +1936,17 @@ impl Wallet { 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(); @@ -1987,6 +1994,7 @@ impl Wallet { 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); @@ -3985,6 +3993,23 @@ mod tests { assert_eq!(restarted.wallet.list_pending_broadcasts().unwrap(), vec![original_txid]); } + #[test] + fn rejected_rbf_clears_the_locally_applied_unconfirmed_marker() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(3); + let replacement = replacement_test_transaction(4); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original); + intent.supersede(replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + node.wallet.note_locally_applied_unconfirmed(replacement_txid); + + node.wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); + + assert!(!node.wallet.take_locally_applied_unconfirmed_txids().contains(&replacement_txid)); + } + #[test] fn observing_an_rbf_predecessor_keeps_the_active_replacement_pending() { let store: Arc = Arc::new(InMemoryStore::new()); @@ -4036,6 +4061,51 @@ mod tests { 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()); From a7c7ce33536c15e0be567bd8da0770c7c8ab5039 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 4 Sep 2026 01:01:17 +0200 Subject: [PATCH 25/30] fix: gate received on broadcast outcome --- src/payment/onchain.rs | 5 +++ src/wallet/mod.rs | 87 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 39288bb2a9..d5c25265a6 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -153,6 +153,7 @@ impl OnchainPayment { e ); } + self.wallet.publish_locally_applied_unconfirmed(txid); Ok(txid) }, Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { @@ -162,6 +163,7 @@ impl OnchainPayment { Err(Self::initial_broadcast_error(error, txid)) }, Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { + self.wallet.publish_locally_applied_unconfirmed(txid); Err(Self::initial_broadcast_error(error, txid)) }, } @@ -729,6 +731,7 @@ impl OnchainPayment { e ); } + self.wallet.publish_locally_applied_unconfirmed(*txid); Ok(*txid) }, Err(error) => Err(Self::rebroadcast_error(error, *txid)), @@ -848,6 +851,7 @@ impl OnchainPayment { e ); } + self.wallet.publish_locally_applied_unconfirmed(replacement_txid); Ok(replacement_txid) }, Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { @@ -857,6 +861,7 @@ impl OnchainPayment { Err(Self::initial_broadcast_error(error, replacement_txid)) }, Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { + self.wallet.publish_locally_applied_unconfirmed(replacement_txid); Err(Self::initial_broadcast_error(error, replacement_txid)) }, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f4ff22a803..6ae12b221d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -454,7 +454,7 @@ pub(crate) struct Wallet { node_metrics: Arc>, logger: Arc, derived_account_lookahead: u32, - locally_applied_unconfirmed_txids: Mutex>, + locally_applied_unconfirmed_txids: Mutex>, } impl Wallet { @@ -1339,15 +1339,38 @@ impl Wallet { } pub(crate) fn take_locally_applied_unconfirmed_txids(&self) -> Vec { - std::mem::take(&mut *self.locally_applied_unconfirmed_txids.lock().unwrap()) + let mut stored = self.locally_applied_unconfirmed_txids.lock().unwrap(); + let mut ready = Vec::new(); + stored.retain(|(txid, publishable)| { + if *publishable { + ready.push(*txid); + false + } else { + true + } + }); + ready } fn note_locally_applied_unconfirmed(&self, txid: Txid) { - self.locally_applied_unconfirmed_txids.lock().unwrap().push(txid); + self.locally_applied_unconfirmed_txids.lock().unwrap().push((txid, false)); + } + + pub(crate) fn publish_locally_applied_unconfirmed(&self, txid: Txid) { + for (stored, publishable) in + self.locally_applied_unconfirmed_txids.lock().unwrap().iter_mut() + { + if *stored == txid { + *publishable = true; + } + } } fn forget_locally_applied_unconfirmed(&self, txids: &[Txid]) { - self.locally_applied_unconfirmed_txids.lock().unwrap().retain(|txid| !txids.contains(txid)); + self.locally_applied_unconfirmed_txids + .lock() + .unwrap() + .retain(|(txid, _)| !txids.contains(txid)); } /// Resolve an accepted broadcast while retaining multi-hop RBF lineage until confirmation. @@ -3590,9 +3613,11 @@ mod tests { 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::time::{SystemTime, UNIX_EPOCH}; + use std::thread; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; struct NamespaceFailStore { inner: InMemoryStore, @@ -4010,6 +4035,58 @@ mod tests { assert!(!node.wallet.take_locally_applied_unconfirmed_txids().contains(&replacement_txid)); } + #[test] + fn unpublished_locally_applied_marker_is_not_consumed() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let txid = replacement_test_transaction(40).compute_txid(); + node.wallet.note_locally_applied_unconfirmed(txid); + assert!(node.wallet.take_locally_applied_unconfirmed_txids().is_empty()); + node.wallet.publish_locally_applied_unconfirmed(txid); + assert_eq!(node.wallet.take_locally_applied_unconfirmed_txids(), vec![txid]); + } + + #[test] + fn concurrent_rbf_rejection_does_not_publish_locally_applied_marker() { + let store: Arc = Arc::new(InMemoryStore::new()); + let node = replacement_test_node(store); + let original = replacement_test_transaction(3); + let replacement = replacement_test_transaction(4); + let replacement_txid = replacement.compute_txid(); + let mut intent = BroadcastIntent::new(original); + intent.supersede(replacement).unwrap(); + node.wallet.write_broadcast_intent(&intent).unwrap(); + + let wallet = Arc::clone(&node.wallet); + let seen = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let taker_wallet = Arc::clone(&wallet); + let taker_seen = Arc::clone(&seen); + let taker_stop = Arc::clone(&stop); + let taker = thread::spawn(move || { + while !taker_stop.load(Ordering::Relaxed) { + taker_seen + .lock() + .unwrap() + .extend(taker_wallet.take_locally_applied_unconfirmed_txids()); + thread::yield_now(); + } + taker_seen + .lock() + .unwrap() + .extend(taker_wallet.take_locally_applied_unconfirmed_txids()); + }); + + wallet.note_locally_applied_unconfirmed(replacement_txid); + thread::sleep(Duration::from_millis(10)); + wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); + stop.store(true, Ordering::Relaxed); + taker.join().unwrap(); + + assert!(!seen.lock().unwrap().contains(&replacement_txid)); + assert!(!wallet.take_locally_applied_unconfirmed_txids().contains(&replacement_txid)); + } + #[test] fn observing_an_rbf_predecessor_keeps_the_active_replacement_pending() { let store: Arc = Arc::new(InMemoryStore::new()); From 054bd6d5aed604cef4168ade5f80d15438a26fc1 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 4 Sep 2026 17:20:16 +0200 Subject: [PATCH 26/30] fix: resolve stack rebase conflicts --- CHANGELOG.md | 11 +++-------- Package.swift | 29 ----------------------------- src/chain/electrum.rs | 26 +++++++++++++------------- 3 files changed, 16 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8057d027db..55bfcf5c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,9 @@ # 0.7.0-rc.67 (Synonym Fork) + ## Bug Fixes -<<<<<<< HEAD -<<<<<<< HEAD -- 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. -======= -======= - `list_pending_broadcasts` now returns each unresolved spend's complete RBF lineage so callers can independently reconcile every replacement before `abandon_pending_broadcast`. ->>>>>>> a58ddaa (fix: address on-chain broadcast review) - 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 @@ -22,7 +16,8 @@ fieldless Kotlin exceptions have an empty generated `message`; callers should match the error variant and use its typed fields. - Electrum transaction rejections are now logged as failures instead of successful broadcasts. ->>>>>>> 6967d92 (docs: place broadcast fixes in current changelog) +- 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) diff --git a/Package.swift b/Package.swift index 2f2d20dda8..fafa771026 100644 --- a/Package.swift +++ b/Package.swift @@ -3,37 +3,8 @@ import PackageDescription -<<<<<<< HEAD -let tag = "v0.7.0-rc.66" -<<<<<<< HEAD -let checksum = "21ac13bfdc9fdd3099a688bd0053f8b14c74b5957943b2f09624886e65556a8e" -======= -let checksum = "3d4ed7123281235353cc7558317c3c15f94e0ff9adcab5fbb1fe35d572662f3d" ->>>>>>> 8fe28e1 (fix: wait for on-chain broadcast results) -======= let tag = "v0.7.0-rc.67" -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD -let checksum = "2bb344a67eccd18708677190104e7769b26e603695f61318e47a794010f29905" ->>>>>>> 049b116 (fix: classify broadcast backend results) -======= -let checksum = "ddbf716ae6d1bdaad8b10fa78abab7af4011956e4eb4d4b002f229dcb94f6c8c" ->>>>>>> d7f3bfc (chore: refresh broadcast result artifacts) -======= -let checksum = "61565a56ecf33a1e8634de5e3be7d388f0579565161b1237c56756cc8b6638ce" ->>>>>>> e359f19 (chore: refresh exact-head Swift artifact) -======= -let checksum = "42bf21b752b53c02267abf68ef38ba2327a3be8662e391121ff6febac1509a97" ->>>>>>> 262ffd0 (Regenerate UniFFI bindings) -======= -let checksum = "d6ccac3a6a013fbb5313d49faf4d3373b14053ede91923e9a1cd4f3230c6848c" ->>>>>>> 833215c (Refresh bindings after abandonment fix) -======= let checksum = "f16f94119be8df627fec4d974f911339ccf74c9b6961feec66841e338f5d4683" ->>>>>>> 072d3cd (fix: address remaining broadcast review) let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index bee520a450..968fd0df0c 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -978,7 +978,19 @@ impl ElectrumRuntimeClient { } } -<<<<<<< HEAD +fn classify_electrum_broadcast_result( + expected_txid: Txid, result: &Result, +) -> Result<(), TxBroadcastError> { + match result { + Ok(returned_txid) => validate_broadcast_txid(expected_txid, *returned_txid), + Err(electrum_client::Error::Protocol(value)) => { + let code = value.get("code").and_then(serde_json::Value::as_i64); + classify_rpc_broadcast_error(code, &value.to_string()) + }, + Err(_) => Err(TxBroadcastError::Failed), + } +} + struct ConfirmGate { active: AtomicBool, } @@ -1061,18 +1073,6 @@ impl Confirm for ShutdownAwareConfirm { self.with_confirmables(Vec::new(), |confirmables| { confirmables.iter().flat_map(|confirmable| confirmable.get_relevant_txids()).collect() }) -======= -fn classify_electrum_broadcast_result( - expected_txid: Txid, result: &Result, -) -> Result<(), TxBroadcastError> { - match result { - Ok(returned_txid) => validate_broadcast_txid(expected_txid, *returned_txid), - Err(electrum_client::Error::Protocol(value)) => { - let code = value.get("code").and_then(serde_json::Value::as_i64); - classify_rpc_broadcast_error(code, &value.to_string()) - }, - Err(_) => Err(TxBroadcastError::Failed), ->>>>>>> 049b116 (fix: classify broadcast backend results) } } From 7a580fab6367ec558abd47aed7e67efaabc12db8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 03:57:07 +0200 Subject: [PATCH 27/30] fix: restore rebroadcast event after restart --- Cargo.lock | 2 +- src/chain/mod.rs | 2 +- src/payment/onchain.rs | 34 ++++++++++++++++++++++++++++++---- src/wallet/mod.rs | 15 ++++++++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5be41f2822..e3651d70e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "ldk-node" -version = "0.7.0-rc.66" +version = "0.7.0-rc.67" dependencies = [ "base64 0.22.1", "bdk-wallet-aggregate", diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 28885d1c6a..42e1146c36 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>, diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index d5c25265a6..7bded77435 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -964,7 +964,7 @@ mod tests { use bitcoin::absolute::LockTime; use bitcoin::hashes::Hash; use bitcoin::transaction::Version; - use bitcoin::{Network, Transaction, Txid}; + use bitcoin::{Amount, Network, ScriptBuf, Transaction, TxOut, Txid}; use lightning::io; use lightning::util::persist::{KVStore, KVStoreSync}; @@ -975,7 +975,7 @@ mod tests { use crate::io::test_utils::InMemoryStore; use crate::tx_broadcaster::TxBroadcastError; use crate::types::DynStore; - use crate::Node; + use crate::{Event, Node}; #[derive(Default)] struct BroadcastWriteState { @@ -1105,6 +1105,15 @@ mod tests { } } + 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(); @@ -1125,9 +1134,10 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn failed_send_survives_eviction_restart_and_retry_until_accepted() { let store: Arc = Arc::new(InMemoryStore::new()); - let tx = test_transaction(); - let txid = tx.compute_txid(); let node = test_node(Arc::clone(&store)); + let 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(); @@ -1169,6 +1179,22 @@ mod tests { 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!(matches!( + restarted_node.next_event(), + Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid + )); + restarted_node.event_handled().unwrap(); + assert!(restarted_node.next_event().is_none()); *restarted_node.is_running.write().unwrap() = false; } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 6ae12b221d..9b53071229 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1353,7 +1353,10 @@ impl Wallet { } fn note_locally_applied_unconfirmed(&self, txid: Txid) { - self.locally_applied_unconfirmed_txids.lock().unwrap().push((txid, false)); + let mut locally_applied = self.locally_applied_unconfirmed_txids.lock().unwrap(); + if !locally_applied.iter().any(|(stored_txid, _)| *stored_txid == txid) { + locally_applied.push((txid, false)); + } } pub(crate) fn publish_locally_applied_unconfirmed(&self, txid: Txid) { @@ -1417,6 +1420,13 @@ impl Wallet { }) .map(|(key, _)| *key) .collect::>(); + let unresolved_txids = pending_intents + .iter() + .filter(|(key, intent)| { + intent.has_pending_transaction() && !confirmed_intent_keys.contains(key) + }) + .map(|(_, intent)| intent.active_txid()) + .collect::>(); let unresolved_txs = pending_intents .into_iter() .filter(|(key, intent)| { @@ -1427,6 +1437,9 @@ impl Wallet { 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); + } self.remove_broadcast_intents(confirmed_intent_keys) } From dcf66116a27f7b9ba410c24a63d60ef720ff6a83 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 04:20:57 +0200 Subject: [PATCH 28/30] chore: refresh rc.67 binding artifacts --- Package.swift | 2 +- bindings/kotlin/ldk-node-jvm/gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index fafa771026..0ccf82ed04 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let tag = "v0.7.0-rc.67" -let checksum = "f16f94119be8df627fec4d974f911339ccf74c9b6961feec66841e338f5d4683" +let checksum = "bae015e73bc12ee10fc7f817241e07e1a0eea9ab125c8b1403b7b2599ebce75e" let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( 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 From 63b045f30ef845094f67b54c78d051dd9a5635ec Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 7 Sep 2026 16:29:31 +0200 Subject: [PATCH 29/30] fix: harden pending broadcast lifecycle --- CHANGELOG.md | 8 +- Cargo.lock | 1 + Cargo.toml | 1 + Package.swift | 2 +- .../ldknode/ldk_node.android.kt | 11 + .../ldknode/ldk_node.common.kt | 11 + bindings/ldk_node.udl | 5 + bindings/python/src/ldk_node/ldk_node.py | 28 ++ bindings/swift/Sources/LDKNode/LDKNode.swift | 22 + crates/bdk-wallet-aggregate/src/lib.rs | 70 ++- src/chain/electrum.rs | 8 +- src/chain/esplora.rs | 6 + src/chain/mod.rs | 65 ++- src/config.rs | 19 + src/event.rs | 36 ++ src/io/mod.rs | 4 + src/payment/onchain.rs | 176 ++++++- src/tx_broadcaster.rs | 74 ++- src/wallet/mod.rs | 466 +++++++++++++++--- 19 files changed, 880 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55bfcf5c55..f21742e4a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ recovery after restart. Callers can explicitly abandon an externally reconciled intent, and RBF replacements use the same result-bearing, durable lifecycle while exposing only the canonical transaction in payment history. -- `NodeError` is now a fielded mobile error type so broadcast failures can expose their transaction - ID. Swift error cases no longer contain the legacy generated `message` associated value, and - fieldless Kotlin exceptions have an empty generated `message`; callers should match the error - variant and use its typed fields. +- `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. diff --git a/Cargo.lock b/Cargo.lock index e3651d70e3..bec108c867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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 ff9c863010..fcc23105fa 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 0ccf82ed04..d2666ec498 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let tag = "v0.7.0-rc.67" -let checksum = "bae015e73bc12ee10fc7f817241e07e1a0eea9ab125c8b1403b7b2599ebce75e" +let checksum = "5b9c642ed48822427cc160d5d4e03f50cd547fed7af64e93ca8a19b2c92b3260" 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/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 6ef1884f68..fa371a9f82 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 @@ -8372,6 +8372,10 @@ 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 { @@ -8523,6 +8527,9 @@ 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 { @@ -8625,6 +8632,10 @@ 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 { 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 5c5bada1f2..0c095da1e8 100644 --- a/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt +++ b/bindings/kotlin/ldk-node-android/lib/src/main/kotlin/org/lightningdevkit/ldknode/ldk_node.common.kt @@ -593,6 +593,10 @@ 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) @@ -623,6 +627,9 @@ 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 @@ -647,6 +654,10 @@ 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 diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 62d8a205f9..4c1cb45428 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -386,10 +386,15 @@ interface OnchainPayment { Txid send_to_address([ByRef]Address address, u64 amount_sats, FeeRate? fee_rate, sequence? utxos_to_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(); + /// 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); [Throws=NodeError] diff --git a/bindings/python/src/ldk_node/ldk_node.py b/bindings/python/src/ldk_node/ldk_node.py index 01dc547b43..5c75b8867b 100644 --- a/bindings/python/src/ldk_node/ldk_node.py +++ b/bindings/python/src/ldk_node/ldk_node.py @@ -6918,6 +6918,11 @@ 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 @@ -6938,6 +6943,10 @@ def calculate_send_all_fee(self, address: "Address",retain_reserves: "bool",fee_ 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 @@ -6954,6 +6963,11 @@ def new_address_info_for_account(self, address_type: "AddressType",account_index 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 @@ -6993,6 +7007,11 @@ def _make_instance_(cls, pointer): 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(), @@ -7175,6 +7194,10 @@ 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(),) ) @@ -7265,6 +7288,11 @@ 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( diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index 481ac2a4e0..ef924f81a0 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -3355,6 +3355,10 @@ 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 @@ -3375,6 +3379,9 @@ 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] @@ -3391,6 +3398,10 @@ 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 @@ -3453,6 +3464,10 @@ 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(), @@ -3544,6 +3559,9 @@ 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) @@ -3598,6 +3616,10 @@ 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(), diff --git a/crates/bdk-wallet-aggregate/src/lib.rs b/crates/bdk-wallet-aggregate/src/lib.rs index 5d3a15251b..dd2a2608ab 100644 --- a/crates/bdk-wallet-aggregate/src/lib.rs +++ b/crates/bdk-wallet-aggregate/src/lib.rs @@ -179,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 @@ -639,12 +653,29 @@ where /// replacements cannot leave the same input reserved after explicit reconciliation. pub fn abandon_txs(&mut self, txs: &[Transaction]) -> Result<(), Error> { let txids = txs.iter().map(Transaction::compute_txid).collect::>(); - let last_seen = self.next_transaction_update_timestamp(&txids)?; - let evicted_txs = txs.iter().map(|tx| (tx.compute_txid(), last_seen)).collect::>(); + let first_evicted_at = self.next_transaction_update_timestamp(&txids)?; for (key, wallet) in self.wallets.iter_mut() { - wallet.apply_evicted_txs(evicted_txs.iter().copied()); - for tx in txs { - wallet.cancel_tx(tx); + // 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| { @@ -1939,6 +1970,35 @@ mod tests { .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; diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 968fd0df0c..6d30292b6d 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -535,7 +535,7 @@ impl ElectrumChainSource { Arc::clone(client) } else { debug_assert!(false, "We should have started the chain source before broadcasting"); - return Err(TxBroadcastError::Failed); + return Err(TxBroadcastError::NotDispatched); }; let mut package_result = Ok(()); @@ -987,6 +987,12 @@ fn classify_electrum_broadcast_result( 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), } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 10625f0e0f..e8e1ebe400 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -590,6 +590,12 @@ fn classify_esplora_broadcast_error(error: &esplora_client::Error) -> Result<(), 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), } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 42e1146c36..7befdd2911 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -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,11 @@ where ); let event = Event::OnchainTransactionReceived { txid, 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)?; }, } }, @@ -566,11 +568,13 @@ where } } - for txid in wallet.take_locally_applied_unconfirmed_txids() { + 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 { @@ -583,10 +587,11 @@ where details.amount_sats ); let event = Event::OnchainTransactionReceived { txid, 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)?; } Ok(()) } @@ -1319,39 +1324,57 @@ impl ChainSource { &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { 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(); - receivers.fail_queued_explicit_requests(); + while let Ok(request) = explicit_receiver.try_recv() { + if request.try_claim() { + if let Some(result_sender) = request.result_sender { + let _ = result_sender.send(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)); + } + } + } log_debug!( tx_bcast_logger, "Stopping broadcasting transactions.", ); return; } - Some(request) = receivers.recv() => { - let package = request.package; - let result_sender = request.result_sender; - let result = match &self.kind { - ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(package).await - }, - ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(package).await - }, - ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(package).await - }, - }; - if let Some(result_sender) = result_sender { - let _ = result_sender.send(result); + Some(request) = ldk_receiver.recv() => { + self.process_broadcast_request(request).await; + } + Some(request) = explicit_receiver.recv() => { + 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 crate::tx_broadcaster::BroadcastRequest { + package, + result_sender, + ldk_claim: _ldk_claim, + explicit_claim: _explicit_claim, + } = request; + let result = match &self.kind { + ChainSourceKind::Esplora(source) => source.process_broadcast_package(package).await, + ChainSourceKind::Electrum(source) => source.process_broadcast_package(package).await, + ChainSourceKind::Bitcoind(source) => 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/event.rs b/src/event.rs index 0ba8cefab1..7d938d0e5e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1013,6 +1013,22 @@ where Ok(()) } + pub(crate) async fn add_event_if_absent(&self, event: Event) -> Result<(), Error> { + let data = { + let mut locked_queue = self.queue.lock().unwrap(); + if !locked_queue.contains(&event) { + locked_queue.push_back(event); + } + EventQueueSerWrapper(&locked_queue).encode() + }; + + self.persist_queue(data).await?; + if let Some(waker) = self.waker.lock().unwrap().take() { + waker.wake(); + } + Ok(()) + } + pub(crate) fn next_event(&self) -> Option { let locked_queue = self.queue.lock().unwrap(); locked_queue.front().cloned() @@ -2548,6 +2564,26 @@ mod tests { assert_eq!(event_queue.next_event(), None); } + #[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 event_queue_concurrency() { let store: Arc = Arc::new(InMemoryStore::new()); diff --git a/src/io/mod.rs b/src/io/mod.rs index 6010fe6621..60fe1754bf 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -32,6 +32,10 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const ONCHAIN_BROADCAST_INTENT_PRIMARY_NAMESPACE: &str = "onchain_broadcast_intents"; pub(crate) const ONCHAIN_BROADCAST_INTENT_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/payment/onchain.rs b/src/payment/onchain.rs index 7bded77435..d7ae1edf38 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -122,6 +122,24 @@ pub struct OnchainPayment { logger: Arc, } +struct BroadcastDispatchLease { + wallet: Arc, + txid: Txid, +} + +impl BroadcastDispatchLease { + fn acquire(wallet: &Arc, txid: Txid) -> Result { + wallet.begin_broadcast_dispatch(txid)?; + Ok(Self { wallet: Arc::clone(wallet), txid }) + } +} + +impl Drop for BroadcastDispatchLease { + fn drop(&mut self) { + self.wallet.end_broadcast_dispatch(&self.txid); + } +} + impl OnchainPayment { pub(crate) fn new( wallet: Arc, tx_broadcaster: Arc, runtime: Arc, @@ -139,12 +157,23 @@ impl OnchainPayment { &self, admission: ExplicitBroadcastAdmission, tx: bitcoin::Transaction, ) -> Result { let txid = tx.compute_txid(); + let dispatch_lease = BroadcastDispatchLease::acquire(&self.wallet, txid)?; self.wallet.prepare_pending_broadcast(&tx)?; - match self + let dispatch_result = match self .runtime - .block_on(self.tx_broadcaster.broadcast_transaction(admission, tx.clone())) + .try_block_on(self.tx_broadcaster.broadcast_transaction(admission, tx.clone())) { + 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.wallet.publish_locally_applied_unconfirmed(txid)?; if let Err(e) = self.wallet.clear_broadcast_intent(&txid) { log_error!( self.logger, @@ -153,17 +182,17 @@ impl OnchainPayment { e ); } - self.wallet.publish_locally_applied_unconfirmed(txid); Ok(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.wallet.publish_locally_applied_unconfirmed(txid); + self.wallet.publish_locally_applied_unconfirmed(txid)?; Err(Self::initial_broadcast_error(error, txid)) }, } @@ -719,10 +748,19 @@ impl OnchainPayment { return Err(Error::NotRunning); } let admission = self.begin_explicit_broadcast()?; + let _dispatch_lease = BroadcastDispatchLease::acquire(&self.wallet, *txid)?; let tx = self.wallet.recover_pending_broadcast(txid)?.ok_or(Error::TransactionNotFound)?; - match self.runtime.block_on(self.tx_broadcaster.broadcast_transaction(admission, tx)) { + let dispatch_result = match self + .runtime + .try_block_on(self.tx_broadcaster.broadcast_transaction(admission, tx)) + { + Ok(result) => result, + Err(error) => return Err(error), + }; + match dispatch_result { Ok(()) => { + self.wallet.publish_locally_applied_unconfirmed(*txid)?; if let Err(e) = self.wallet.clear_broadcast_intent(txid) { log_error!( self.logger, @@ -731,7 +769,6 @@ impl OnchainPayment { e ); } - self.wallet.publish_locally_applied_unconfirmed(*txid); Ok(*txid) }, Err(error) => Err(Self::rebroadcast_error(error, *txid)), @@ -765,8 +802,9 @@ impl OnchainPayment { /// their pending payment records. If the active transaction is later observed, ordinary wallet /// sync will record it again. /// - /// Returns [`Error::NotRunning`] if the node is stopped and [`Error::TransactionNotFound`] if - /// `txid` is not the active transaction returned by [`Self::list_pending_broadcasts`]. + /// 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 /// @@ -828,6 +866,7 @@ impl OnchainPayment { return Err(Error::NotRunning); } let admission = self.begin_explicit_broadcast()?; + let _dispatch_lease = BroadcastDispatchLease::acquire(&self.wallet, *txid)?; // Pass through to the wallet implementation #[cfg(not(feature = "uniffi"))] @@ -838,11 +877,20 @@ impl OnchainPayment { let replacement = self.wallet.prepare_rbf_broadcast(txid, fee_rate_param, &self.channel_manager)?; let replacement_txid = replacement.compute_txid(); - match self + let dispatch_result = match self .runtime - .block_on(self.tx_broadcaster.broadcast_transaction(admission, replacement)) + .try_block_on(self.tx_broadcaster.broadcast_transaction(admission, replacement)) { + 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.wallet.publish_locally_applied_unconfirmed(replacement_txid)?; if let Err(e) = self.wallet.clear_broadcast_intent(&replacement_txid) { log_error!( self.logger, @@ -851,7 +899,6 @@ impl OnchainPayment { e ); } - self.wallet.publish_locally_applied_unconfirmed(replacement_txid); Ok(replacement_txid) }, Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { @@ -861,7 +908,7 @@ impl OnchainPayment { Err(Self::initial_broadcast_error(error, replacement_txid)) }, Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { - self.wallet.publish_locally_applied_unconfirmed(replacement_txid); + self.wallet.publish_locally_applied_unconfirmed(replacement_txid)?; Err(Self::initial_broadcast_error(error, replacement_txid)) }, } @@ -1150,6 +1197,21 @@ mod tests { initial_request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); 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); @@ -1189,13 +1251,95 @@ mod tests { ) .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.result_sender.unwrap().send(Ok(())).unwrap(); + 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_node.next_event(), + restarted.next_event(), Some(Event::OnchainTransactionReceived { txid: received_txid, .. }) if received_txid == txid )); - restarted_node.event_handled().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 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.publish_locally_applied_unconfirmed(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.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + drop(receivers); + assert_eq!(dispatch.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index e553b0652d..dd35bdd850 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -38,6 +38,7 @@ pub(crate) fn classify_rpc_broadcast_error( 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)) }; @@ -69,6 +70,9 @@ pub(crate) fn classify_rpc_broadcast_error( "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)) @@ -93,7 +97,7 @@ const EXPLICIT_BROADCAST_QUEUED: u8 = 0; const EXPLICIT_BROADCAST_CLAIMED: u8 = 1; const EXPLICIT_BROADCAST_CANCELLED: u8 = 2; -struct ExplicitBroadcastClaim { +pub(crate) struct ExplicitBroadcastClaim { state: AtomicU8, } @@ -138,7 +142,19 @@ impl Drop for CancelExplicitBroadcastOnDrop { pub(crate) struct BroadcastRequest { pub(crate) package: Vec, pub(crate) result_sender: Option>>, - explicit_claim: 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 { @@ -151,28 +167,37 @@ impl BroadcastRequest { package, result_sender: Some(result_sender), explicit_claim: Some(Arc::clone(&explicit_claim)), + ldk_claim: None, }, explicit_claim, ) } - fn ldk(package: Vec) -> Self { - Self { package, result_sender: None, explicit_claim: None } + 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 }), + } } - fn try_claim(&self) -> bool { + pub(crate) fn try_claim(&self) -> bool { self.explicit_claim.as_ref().map_or(true, |claim| claim.try_claim()) } } /// Separate receivers for safety-critical LDK traffic and bounded explicit user sends. pub(crate) struct BroadcastQueueReceivers { - ldk_receiver: mpsc::UnboundedReceiver, - explicit_receiver: mpsc::Receiver, + pub(crate) ldk_receiver: mpsc::UnboundedReceiver, + pub(crate) explicit_receiver: mpsc::Receiver, } impl BroadcastQueueReceivers { - /// Returns the next request, prioritizing LDK traffic when both queues are ready. + #[cfg(test)] pub(crate) async fn recv(&mut self) -> Option { loop { let request = tokio::select! { @@ -206,6 +231,7 @@ where L::Target: LdkLogger, { ldk_sender: mpsc::UnboundedSender, + ldk_queued_packages: Arc>>>, explicit_sender: mpsc::Sender, queue_receivers: Mutex, explicit_broadcast_run: std::sync::Mutex>>, @@ -223,11 +249,13 @@ where { pub(crate) fn new(logger: L) -> Self { 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))), @@ -313,7 +341,11 @@ where { fn broadcast_transactions(&self, txs: &[&Transaction]) { let package = txs.iter().map(|&t| t.clone()).collect::>(); - let request = BroadcastRequest::ldk(package); + 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); }); @@ -363,6 +395,13 @@ mod tests { 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) @@ -575,6 +614,23 @@ mod tests { 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())); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9b53071229..01b54912e2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -61,6 +61,7 @@ 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, }; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; @@ -76,9 +77,28 @@ const MAX_ADDRESS_INFO_BATCH_COUNT: u32 = bdk_wallet_aggregate::MAX_ADDRESS_INFO const LEGACY_ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 1; const LEGACY_RBF_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 2; const LEGACY_RESOLVED_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 3; -const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 4; +const LEGACY_LINEAGE_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 4; +const ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION: u8 = 5; 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, @@ -123,6 +143,7 @@ struct BroadcastIntent { // still needed reconciliation. This makes rejection rollback durable even after branching. predecessor_indexes: Vec, predecessor_was_pending: Vec, + participant_accounts: Vec, } impl BroadcastIntent { @@ -133,19 +154,21 @@ impl BroadcastIntent { state: BroadcastIntentState::Pending, predecessor_indexes: vec![NO_PREDECESSOR_INDEX], predecessor_was_pending: vec![0], + participant_accounts: Vec::new(), } } 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(replacement)?; + intent.supersede_from(original_txid, replacement)?; Ok(intent) } @@ -171,7 +194,14 @@ impl BroadcastIntent { } else { BroadcastIntentState::Pending }; - Ok(Self { transactions, active_index, state, predecessor_indexes, predecessor_was_pending }) + Ok(Self { + transactions, + active_index, + state, + predecessor_indexes, + predecessor_was_pending, + participant_accounts: Vec::new(), + }) } fn key(&self) -> Txid { @@ -256,22 +286,32 @@ impl BroadcastIntent { Ok(()) } - fn supersede(&mut self, replacement: Transaction) -> Result<(), Error> { + 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.active_transaction().input.iter().any(|active_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_index = u64::from(self.active_index); - let predecessor_was_pending = u8::from(self.state == BroadcastIntentState::Pending); + 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(predecessor_index); + 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)?; @@ -279,6 +319,11 @@ impl BroadcastIntent { 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 @@ -374,8 +419,17 @@ fn observed_broadcast_intents( { return Some((*key, txid, true)); } - let active_txid = intent.active_txid(); - observed_txids.contains(&active_txid).then_some((*key, active_txid, false)) + 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() } @@ -439,6 +493,8 @@ pub(crate) struct Wallet { 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, @@ -454,7 +510,6 @@ pub(crate) struct Wallet { node_metrics: Arc>, logger: Arc, derived_account_lookahead: u32, - locally_applied_unconfirmed_txids: Mutex>, } impl Wallet { @@ -482,6 +537,7 @@ impl Wallet { 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()), @@ -496,10 +552,21 @@ impl Wallet { node_metrics, logger, derived_account_lookahead, - locally_applied_unconfirmed_txids: Mutex::new(Vec::new()), } } + 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_dispatch(&self, txid: &Txid) { + self.inflight_broadcast_txids.lock().unwrap().remove(txid); + } + pub(crate) fn is_funding_transaction( &self, txid: &Txid, channel_manager: &ChannelManager, ) -> bool { @@ -990,6 +1057,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(()); } @@ -1027,6 +1097,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(()) } @@ -1040,6 +1112,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() @@ -1220,8 +1302,11 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); self.complete_broadcast_transitions()?; let txid = tx.compute_txid(); - self.write_broadcast_intent(&BroadcastIntent::new(tx.clone()))?; let mut locked_wallet = self.inner.lock().unwrap(); + let 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()) { @@ -1232,7 +1317,6 @@ impl Wallet { log_error!(self.logger, "Failed to index pending transaction {}: {}", txid, e); return Err(Error::OnchainTxBroadcastFailed { txid }); } - self.note_locally_applied_unconfirmed(txid); Ok(()) } @@ -1247,6 +1331,14 @@ impl Wallet { 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) @@ -1271,7 +1363,7 @@ impl Wallet { for intent_txid in &txids { self.payment_store.remove(&PaymentId(intent_txid.to_byte_array()))?; } - self.forget_locally_applied_unconfirmed(&txids); + self.forget_locally_applied_unconfirmed(&txids)?; self.remove_broadcast_intent(&intent_key) } @@ -1338,42 +1430,124 @@ impl Wallet { .collect()) } - pub(crate) fn take_locally_applied_unconfirmed_txids(&self) -> Vec { - let mut stored = self.locally_applied_unconfirmed_txids.lock().unwrap(); - let mut ready = Vec::new(); - stored.retain(|(txid, publishable)| { - if *publishable { - ready.push(*txid); - false - } else { - true - } - }); + 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 note_locally_applied_unconfirmed(&self, txid: Txid) { - let mut locally_applied = self.locally_applied_unconfirmed_txids.lock().unwrap(); - if !locally_applied.iter().any(|(stored_txid, _)| *stored_txid == txid) { - locally_applied.push((txid, false)); + 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), } } - pub(crate) fn publish_locally_applied_unconfirmed(&self, txid: Txid) { - for (stored, publishable) in - self.locally_applied_unconfirmed_txids.lock().unwrap().iter_mut() - { - if *stored == txid { - *publishable = true; - } + fn 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)); + if belongs_to_intent { + 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 forget_locally_applied_unconfirmed(&self, txids: &[Txid]) { - self.locally_applied_unconfirmed_txids + fn cleanup_delivered_broadcast_event_if_resolved(&self, txid: Txid) -> Result<(), Error> { + let still_tracked = self + .broadcast_intents .lock() .unwrap() - .retain(|(txid, _)| !txids.contains(txid)); + .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. @@ -1382,7 +1556,8 @@ impl Wallet { self.complete_broadcast_transitions()?; let (intent_key, intent) = self.find_broadcast_intent_by_active_txid(txid).ok_or(Error::TransactionNotFound)?; - self.resolve_broadcast_intent(intent_key, intent, *txid, false) + self.resolve_broadcast_intent(intent_key, intent, *txid, false)?; + self.cleanup_delivered_broadcast_event_if_resolved(*txid) } /// Restore unresolved intent reservations after loading the wallet from persistent storage. @@ -1411,36 +1586,23 @@ impl Wallet { } let mut locked_wallet = self.inner.lock().unwrap(); - let confirmed_txids = - locked_wallet.transaction_confirmations().keys().copied().collect::>(); - let confirmed_intent_keys = pending_intents - .iter() - .filter(|(_, intent)| { - intent.transactions.iter().any(|tx| confirmed_txids.contains(&tx.compute_txid())) - }) - .map(|(key, _)| *key) - .collect::>(); let unresolved_txids = pending_intents .iter() - .filter(|(key, intent)| { - intent.has_pending_transaction() && !confirmed_intent_keys.contains(key) - }) + .filter(|(_, intent)| intent.has_pending_transaction()) .map(|(_, intent)| intent.active_txid()) .collect::>(); let unresolved_txs = pending_intents .into_iter() - .filter(|(key, intent)| { - intent.has_pending_transaction() && !confirmed_intent_keys.contains(key) - }) + .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); + self.note_locally_applied_unconfirmed(txid)?; } - self.remove_broadcast_intents(confirmed_intent_keys) + Ok(()) } fn reapply_unresolved_broadcasts( @@ -1507,6 +1669,16 @@ impl Wallet { intent.state.serialization_tag(), &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( @@ -1533,6 +1705,19 @@ impl Wallet { .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> { @@ -1588,7 +1773,7 @@ impl Wallet { })?; BroadcastIntent::from_legacy(transactions, first_pending_index)? }, - Some(&ONCHAIN_BROADCAST_INTENT_SERIALIZATION_VERSION) => { + Some(&LEGACY_LINEAGE_BROADCAST_INTENT_SERIALIZATION_VERSION) => { let ( active_index, state_tag, @@ -1620,6 +1805,47 @@ impl Wallet { state, predecessor_indexes, predecessor_was_pending, + participant_accounts: Vec::new(), + } + }, + Some(&ONCHAIN_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, } }, _ => { @@ -1695,13 +1921,6 @@ impl Wallet { Ok(()) } - fn remove_broadcast_intents(&self, txids: impl IntoIterator) -> Result<(), Error> { - for txid in txids { - self.remove_broadcast_intent(&txid)?; - } - Ok(()) - } - fn stage_broadcast_resolutions(&self, resolutions: &[(Txid, Txid, bool)]) -> Result<(), Error> { for (intent_key, observed_txid, confirmed) in resolutions { let mut intent = self @@ -1736,6 +1955,9 @@ impl Wallet { }) .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)?; @@ -1755,6 +1977,11 @@ impl Wallet { 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> { @@ -1823,12 +2050,14 @@ impl Wallet { self.update_payment_store(&locked_wallet)?; drop(locked_wallet); + self.publish_locally_applied_unconfirmed(observed_txid)?; intent.state = BroadcastIntentState::Accepted; if intent.transactions.len() == 1 { - self.remove_broadcast_intent(&intent_key) + self.remove_broadcast_intent(&intent_key)?; } else { - self.write_broadcast_intent(&intent) + self.write_broadcast_intent(&intent)?; } + self.cleanup_delivered_broadcast_event_if_resolved(observed_txid) } fn resolve_broadcast_intents( @@ -1857,10 +2086,19 @@ impl Wallet { 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)?; if intent.transactions.len() == 1 { self.remove_broadcast_intent(&intent_key)?; } else { @@ -1872,6 +2110,7 @@ impl Wallet { self.payment_store.remove(&PaymentId(txid.to_byte_array()))?; } } + self.cleanup_delivered_broadcast_event_if_resolved(observed_txid)?; Ok(()) } @@ -1895,7 +2134,7 @@ impl Wallet { let _intent = self.broadcast_intent_lock.lock().unwrap(); self.complete_broadcast_transitions()?; - let existing_intent = self.find_broadcast_intent_by_active_txid(txid); + let existing_intent = self.find_broadcast_intent_by_lineage_txid(txid); let mut locked_wallet = self.inner.lock().unwrap(); let (tx, original_fee) = locked_wallet.build_rbf(*txid, fee_rate).map_err(|e| match e { @@ -1938,7 +2177,14 @@ impl Wallet { original_tx, tx.clone(), )?; + let mut replacement_intent = replacement_intent; + 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); @@ -1948,8 +2194,6 @@ impl Wallet { log_error!(self.logger, "Failed to index RBF replacement {}: {}", new_txid, e); return Err(Error::OnchainTxBroadcastFailed { txid: new_txid }); } - self.note_locally_applied_unconfirmed(new_txid); - // Calculate and log the actual fee increase achieved let new_fee = locked_wallet.calculate_tx_fee(&tx).unwrap_or(Amount::ZERO); let actual_fee_rate = new_fee / tx.weight(); @@ -2030,7 +2274,7 @@ impl Wallet { 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]); + self.forget_locally_applied_unconfirmed(&[rejected_txid])?; let finished_rejected_tx = intent.finish_rejection()?; debug_assert_eq!(finished_rejected_tx.compute_txid(), rejected_txid); @@ -3891,6 +4135,38 @@ mod tests { 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 }]; + 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()); @@ -4041,7 +4317,7 @@ mod tests { let mut intent = BroadcastIntent::new(original); intent.supersede(replacement).unwrap(); node.wallet.write_broadcast_intent(&intent).unwrap(); - node.wallet.note_locally_applied_unconfirmed(replacement_txid); + node.wallet.note_locally_applied_unconfirmed(replacement_txid).unwrap(); node.wallet.reject_rbf_broadcast(&replacement_txid).unwrap(); @@ -4053,9 +4329,9 @@ mod tests { 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); + 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); + node.wallet.publish_locally_applied_unconfirmed(txid).unwrap(); assert_eq!(node.wallet.take_locally_applied_unconfirmed_txids(), vec![txid]); } @@ -4090,7 +4366,7 @@ mod tests { .extend(taker_wallet.take_locally_applied_unconfirmed_txids()); }); - wallet.note_locally_applied_unconfirmed(replacement_txid); + 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); @@ -4120,6 +4396,44 @@ mod tests { ); } + #[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()); From 23652b26a2511a270d5048ed1b4601769f21dd6f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 7 Sep 2026 18:07:39 +0200 Subject: [PATCH 30/30] fix: retain broadcast dispatch outcomes --- Package.swift | 2 +- src/chain/electrum.rs | 18 ++- src/chain/mod.rs | 19 ++- src/lib.rs | 2 +- src/payment/onchain.rs | 329 +++++++++++++++++++++++++++++++++-------- src/tx_broadcaster.rs | 55 +++++-- src/wallet/mod.rs | 50 ++++++- 7 files changed, 378 insertions(+), 97 deletions(-) diff --git a/Package.swift b/Package.swift index d2666ec498..c76e528e35 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let tag = "v0.7.0-rc.67" -let checksum = "5b9c642ed48822427cc160d5d4e03f50cd547fed7af64e93ca8a19b2c92b3260" +let checksum = "a5f64657c667232083b14aa658235683418f1282449344c3d7c48ed7dc396e65" let url = "https://github.com/synonymdev/ldk-node/releases/download/\(tag)/LDKNodeFFI.xcframework.zip" let package = Package( diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 6d30292b6d..a05881cbe6 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -42,7 +42,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, validate_broadcast_txid, TxBroadcastError, + classify_rpc_broadcast_error, validate_broadcast_txid, ExplicitBroadcastGuard, TxBroadcastError, }; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::NodeMetrics; @@ -528,7 +528,7 @@ impl ElectrumChainSource { } pub(crate) async fn process_broadcast_package( - &self, package: Vec, + &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() { @@ -540,7 +540,7 @@ impl ElectrumChainSource { let mut package_result = Ok(()); for tx in package { - let result = electrum_client.broadcast(tx).await; + let result = electrum_client.broadcast(tx, explicit_guard.clone()).await; if package_result.is_ok() { package_result = result; } @@ -841,14 +841,18 @@ impl ElectrumRuntimeClient { }) } - async fn broadcast(&self, tx: Transaction) -> Result<(), TxBroadcastError> { + 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 spawn_fut = self.runtime_handle.spawn_blocking(move || { + let _explicit_guard = explicit_guard; + electrum_client.transaction_broadcast(&tx) + }); match spawn_fut.await { Ok(broadcast_result) => { @@ -1277,7 +1281,7 @@ mod tests { output: vec![], }; - assert_eq!(runtime.block_on(client.broadcast(tx)), Err(TxBroadcastError::Rejected)); + assert_eq!(runtime.block_on(client.broadcast(tx, None)), Err(TxBroadcastError::Rejected)); server_thread.join().unwrap(); } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 7befdd2911..e310950abb 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -1334,9 +1334,7 @@ impl ChainSource { self.tx_broadcaster.pause_explicit_broadcasts(); while let Ok(request) = explicit_receiver.try_recv() { if request.try_claim() { - if let Some(result_sender) = request.result_sender { - let _ = result_sender.send(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)); - } + request.send_result(Err(crate::tx_broadcaster::TxBroadcastError::NotDispatched)); } } log_debug!( @@ -1365,11 +1363,20 @@ impl ChainSource { result_sender, ldk_claim: _ldk_claim, explicit_claim: _explicit_claim, + explicit_guard, } = request; let result = match &self.kind { - ChainSourceKind::Esplora(source) => source.process_broadcast_package(package).await, - ChainSourceKind::Electrum(source) => source.process_broadcast_package(package).await, - ChainSourceKind::Bitcoind(source) => source.process_broadcast_package(package).await, + 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); diff --git a/src/lib.rs b/src/lib.rs index e3a2e19f28..8d460f7182 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2757,7 +2757,7 @@ mod tests { let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; let second_request = receivers.recv().await.unwrap(); assert_eq!(second_request.package, vec![tx]); - second_request.result_sender.unwrap().send(Ok(())).unwrap(); + second_request.send_result(Ok(())); drop(receivers); assert_eq!(second_call.await.unwrap(), Ok(txid)); assert!(node.wallet.list_pending_broadcasts().unwrap().is_empty()); diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index d7ae1edf38..0108bbbc1a 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -7,7 +7,7 @@ //! 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}; @@ -16,7 +16,7 @@ use crate::error::Error; use crate::fee_estimator::ConfirmationTarget; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::runtime::RuntimeControl; -use crate::tx_broadcaster::{ExplicitBroadcastAdmission, TxBroadcastError}; +use crate::tx_broadcaster::{ExplicitBroadcastAdmission, ExplicitBroadcastGuard, TxBroadcastError}; use crate::types::{Broadcaster, ChannelManager, SpendableUtxo, Wallet}; use crate::wallet::{CoinSelectionAlgorithm, OnchainSendAmount}; @@ -124,19 +124,28 @@ pub struct OnchainPayment { struct BroadcastDispatchLease { wallet: Arc, - txid: Txid, + txids: Mutex>, } impl BroadcastDispatchLease { fn acquire(wallet: &Arc, txid: Txid) -> Result { wallet.begin_broadcast_dispatch(txid)?; - Ok(Self { wallet: Arc::clone(wallet), 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_dispatch(&self.txid); + self.wallet.end_broadcast_dispatches(&self.txids.lock().unwrap()); } } @@ -157,12 +166,12 @@ impl OnchainPayment { &self, admission: ExplicitBroadcastAdmission, tx: bitcoin::Transaction, ) -> Result { let txid = tx.compute_txid(); - let dispatch_lease = BroadcastDispatchLease::acquire(&self.wallet, txid)?; + let dispatch_lease = Arc::new(BroadcastDispatchLease::acquire(&self.wallet, txid)?); self.wallet.prepare_pending_broadcast(&tx)?; - let dispatch_result = match self - .runtime - .try_block_on(self.tx_broadcaster.broadcast_transaction(admission, tx.clone())) - { + 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); @@ -172,18 +181,7 @@ impl OnchainPayment { Err(error) => return Err(error), }; match dispatch_result { - Ok(()) => { - self.wallet.publish_locally_applied_unconfirmed(txid)?; - if let Err(e) = self.wallet.clear_broadcast_intent(&txid) { - log_error!( - self.logger, - "Failed to clear accepted broadcast intent {}: {}", - txid, - e - ); - } - Ok(txid) - }, + Ok(()) => Ok(self.record_accepted_broadcast(txid)), Err(error @ (TxBroadcastError::Rejected | TxBroadcastError::NotDispatched)) => { drop(dispatch_lease); if self.wallet.abandon_broadcast_intent(&tx).is_err() { @@ -192,12 +190,30 @@ impl OnchainPayment { Err(Self::initial_broadcast_error(error, txid)) }, Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { - self.wallet.publish_locally_applied_unconfirmed(txid)?; + self.record_unknown_broadcast(txid); Err(Self::initial_broadcast_error(error, txid)) }, } } + fn record_accepted_broadcast(&self, txid: Txid) -> Txid { + if let Err(e) = self.wallet.clear_broadcast_intent(&txid) { + log_error!(self.logger, "Failed to persist accepted broadcast outcome {}: {}", txid, e); + } + txid + } + + fn record_unknown_broadcast(&self, txid: Txid) { + if let Err(e) = self.wallet.publish_locally_applied_unconfirmed(txid) { + log_error!( + self.logger, + "Failed to persist acceptance-unknown broadcast event {}: {}", + txid, + e + ); + } + } + fn initial_broadcast_error(error: TxBroadcastError, txid: Txid) -> Error { match error { TxBroadcastError::Rejected => Error::OnchainTxBroadcastRejected { txid }, @@ -748,30 +764,22 @@ impl OnchainPayment { return Err(Error::NotRunning); } let admission = self.begin_explicit_broadcast()?; - let _dispatch_lease = BroadcastDispatchLease::acquire(&self.wallet, *txid)?; + let dispatch_lease = Arc::new(BroadcastDispatchLease::acquire(&self.wallet, *txid)?); let tx = self.wallet.recover_pending_broadcast(txid)?.ok_or(Error::TransactionNotFound)?; - let dispatch_result = match self - .runtime - .try_block_on(self.tx_broadcaster.broadcast_transaction(admission, tx)) - { + 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.wallet.publish_locally_applied_unconfirmed(*txid)?; - if let Err(e) = self.wallet.clear_broadcast_intent(txid) { - log_error!( - self.logger, - "Failed to clear accepted rebroadcast intent {}: {}", - txid, - e - ); - } - Ok(*txid) + Ok(()) => Ok(self.record_accepted_broadcast(*txid)), + Err(error) => { + self.record_unknown_broadcast(*txid); + Err(Self::rebroadcast_error(error, *txid)) }, - Err(error) => Err(Self::rebroadcast_error(error, *txid)), } } @@ -866,7 +874,7 @@ impl OnchainPayment { return Err(Error::NotRunning); } let admission = self.begin_explicit_broadcast()?; - let _dispatch_lease = BroadcastDispatchLease::acquire(&self.wallet, *txid)?; + let dispatch_lease = Arc::new(BroadcastDispatchLease::acquire(&self.wallet, *txid)?); // Pass through to the wallet implementation #[cfg(not(feature = "uniffi"))] @@ -877,10 +885,11 @@ impl OnchainPayment { let replacement = self.wallet.prepare_rbf_broadcast(txid, fee_rate_param, &self.channel_manager)?; let replacement_txid = replacement.compute_txid(); - let dispatch_result = match self - .runtime - .try_block_on(self.tx_broadcaster.broadcast_transaction(admission, replacement)) - { + 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)?; @@ -889,18 +898,7 @@ impl OnchainPayment { Err(error) => return Err(error), }; match dispatch_result { - Ok(()) => { - self.wallet.publish_locally_applied_unconfirmed(replacement_txid)?; - if let Err(e) = self.wallet.clear_broadcast_intent(&replacement_txid) { - log_error!( - self.logger, - "Failed to clear accepted RBF broadcast intent {}: {}", - replacement_txid, - e - ); - } - Ok(replacement_txid) - }, + Ok(()) => 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 }); @@ -908,7 +906,7 @@ impl OnchainPayment { Err(Self::initial_broadcast_error(error, replacement_txid)) }, Err(error @ (TxBroadcastError::Failed | TxBroadcastError::Timeout)) => { - self.wallet.publish_locally_applied_unconfirmed(replacement_txid)?; + self.record_unknown_broadcast(replacement_txid); Err(Self::initial_broadcast_error(error, replacement_txid)) }, } @@ -1035,6 +1033,7 @@ mod tests { inner: InMemoryStore, state: Mutex, state_changed: Condvar, + fail_next_write_namespace: Mutex>, } impl BlockingBroadcastIntentStore { @@ -1043,9 +1042,14 @@ mod tests { 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 }; @@ -1079,6 +1083,16 @@ mod tests { } 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 { @@ -1118,6 +1132,7 @@ mod tests { &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) } @@ -1194,7 +1209,7 @@ mod tests { let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; let initial_request = receivers.recv().await.unwrap(); assert_eq!(initial_request.package, vec![tx.clone()]); - initial_request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + initial_request.send_result(Err(TxBroadcastError::Failed)); drop(receivers); assert_eq!(initial_call.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); crate::chain::process_wallet_events( @@ -1226,7 +1241,7 @@ mod tests { let mut receivers = restarted_node.tx_broadcaster.get_broadcast_queue_receivers().await; let retry_request = receivers.recv().await.unwrap(); assert_eq!(retry_request.package, vec![tx.clone()]); - retry_request.result_sender.unwrap().send(Err(TxBroadcastError::NotDispatched)).unwrap(); + 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]); @@ -1237,7 +1252,7 @@ mod tests { let mut receivers = restarted_node.tx_broadcaster.get_broadcast_queue_receivers().await; let accepted_request = receivers.recv().await.unwrap(); assert_eq!(accepted_request.package, vec![tx]); - accepted_request.result_sender.unwrap().send(Ok(())).unwrap(); + 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()); @@ -1269,7 +1284,7 @@ mod tests { }); let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); - request.result_sender.unwrap().send(Ok(())).unwrap(); + request.send_result(Ok(())); drop(receivers); assert_eq!(send.await.unwrap(), Ok(txid)); drop(node); @@ -1337,11 +1352,197 @@ mod tests { node.wallet.abandon_broadcast_intent_by_txid(&txid), Err(Error::OnchainTxBroadcastFailed { txid }) ); - request.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + 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 crate::tx_broadcaster::BroadcastRequest { + result_sender, + explicit_guard, + 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_preserves_result_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 crate::tx_broadcaster::BroadcastRequest { + result_sender, + explicit_guard, + package: _, + explicit_claim: _, + ldk_claim: _, + } = request; + result_sender.unwrap().send(Ok(())).unwrap(); + drop(explicit_guard); + drop(receivers); + + assert_eq!(dispatch.await.unwrap(), Ok(txid)); + assert_eq!(node.wallet.list_pending_broadcasts().unwrap(), vec![txid]); + assert!(node.wallet.ready_locally_applied_unconfirmed_txids().unwrap().is_empty()); + 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 crate::tx_broadcaster::BroadcastRequest { + result_sender, + explicit_guard, + 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 crate::tx_broadcaster::BroadcastRequest { + result_sender, + explicit_guard, + 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()); @@ -1355,7 +1556,7 @@ mod tests { let retry = tokio::task::spawn_blocking(move || payment.rebroadcast_transaction(&txid)); let mut receivers = node.tx_broadcaster.get_broadcast_queue_receivers().await; let request = receivers.recv().await.unwrap(); - request.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); + request.send_result(Err(TxBroadcastError::Rejected)); drop(receivers); assert_eq!(retry.await.unwrap(), Err(Error::OnchainTxBroadcastFailed { txid })); diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index dd35bdd850..1218759a9c 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -27,6 +27,8 @@ pub(crate) enum TxBroadcastError { Timeout, } +pub(crate) type ExplicitBroadcastGuard = Arc; + pub(crate) fn classify_rpc_broadcast_error( code: Option, message: &str, ) -> Result<(), TxBroadcastError> { @@ -143,6 +145,7 @@ pub(crate) struct BroadcastRequest { pub(crate) package: Vec, pub(crate) result_sender: Option>>, pub(crate) explicit_claim: Option>, + pub(crate) explicit_guard: Option, pub(crate) ldk_claim: Option, } @@ -160,6 +163,7 @@ impl Drop for LdkBroadcastClaim { impl BroadcastRequest { fn explicit( package: Vec, result_sender: oneshot::Sender>, + explicit_guard: Option, ) -> (Self, Arc) { let explicit_claim = Arc::new(ExplicitBroadcastClaim::new()); ( @@ -167,6 +171,7 @@ impl BroadcastRequest { package, result_sender: Some(result_sender), explicit_claim: Some(Arc::clone(&explicit_claim)), + explicit_guard, ldk_claim: None, }, explicit_claim, @@ -181,6 +186,7 @@ impl BroadcastRequest { package, result_sender: None, explicit_claim: None, + explicit_guard: None, ldk_claim: Some(LdkBroadcastClaim { key, queued_packages }), } } @@ -188,6 +194,14 @@ impl BroadcastRequest { 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_guard, .. } = self; + drop(explicit_guard); + if let Some(result_sender) = result_sender { + let _ = result_sender.send(result); + } + } } /// Separate receivers for safety-critical LDK traffic and bounded explicit user sends. @@ -219,9 +233,7 @@ impl BroadcastQueueReceivers { if !request.try_claim() { continue; } - if let Some(result_sender) = request.result_sender { - let _ = result_sender.send(Err(TxBroadcastError::NotDispatched)); - } + request.send_result(Err(TxBroadcastError::NotDispatched)); } } } @@ -300,20 +312,24 @@ where 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 (result_sender, result_receiver) = oneshot::channel(); - let (request, explicit_claim) = BroadcastRequest::explicit(vec![tx], result_sender); + let (request, explicit_claim) = + BroadcastRequest::explicit(vec![tx], result_sender, 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)) @@ -412,11 +428,11 @@ mod tests { async fn explicit_broadcast_returns_after_backend_acceptance() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let admission = broadcaster.begin_explicit_broadcast().unwrap(); - let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); + let 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.result_sender.unwrap().send(Ok(())).unwrap(); + request.send_result(Ok(())); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -427,11 +443,11 @@ mod tests { async fn explicit_broadcast_propagates_backend_rejection() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let admission = broadcaster.begin_explicit_broadcast().unwrap(); - let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); + let 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.result_sender.unwrap().send(Err(TxBroadcastError::Rejected)).unwrap(); + request.send_result(Err(TxBroadcastError::Rejected)); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -442,11 +458,11 @@ mod tests { async fn explicit_broadcast_propagates_backend_failure() { let broadcaster = Arc::new(TransactionBroadcaster::new(Arc::new(TestLogger::new()))); let admission = broadcaster.begin_explicit_broadcast().unwrap(); - let broadcast_fut = broadcaster.broadcast_transaction(admission, test_transaction()); + let 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.result_sender.unwrap().send(Err(TxBroadcastError::Failed)).unwrap(); + request.send_result(Err(TxBroadcastError::Failed)); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -461,12 +477,13 @@ mod tests { 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.result_sender.unwrap().send(Ok(())).unwrap(); + request.send_result(Ok(())); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -482,6 +499,7 @@ mod tests { admission, test_transaction(), Duration::from_millis(10), + None, ) .await; assert_eq!(cancelled_result, Err(TxBroadcastError::NotDispatched)); @@ -492,12 +510,13 @@ mod tests { 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.result_sender.unwrap().send(Ok(())).unwrap(); + request.send_result(Ok(())); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -515,6 +534,7 @@ mod tests { admission, test_transaction(), Duration::from_secs(1), + None, ) .await }); @@ -529,12 +549,13 @@ mod tests { 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.result_sender.unwrap().send(Ok(())).unwrap(); + request.send_result(Ok(())); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -549,6 +570,7 @@ mod tests { admission, test_transaction(), Duration::from_secs(1), + None, ); let stop_fut = async { tokio::task::yield_now().await; @@ -579,6 +601,7 @@ mod tests { stale_admission, test_transaction(), Duration::from_secs(1), + None, ) .await, Err(TxBroadcastError::NotDispatched) @@ -590,12 +613,13 @@ mod tests { 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.result_sender.unwrap().send(Ok(())).unwrap(); + request.send_result(Ok(())); }; let (result, ()) = tokio::join!(broadcast_fut, process_fut); @@ -637,7 +661,7 @@ mod tests { 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); + BroadcastRequest::explicit(vec![test_transaction()], result_sender, None); broadcaster.explicit_sender.try_send(request).unwrap(); } @@ -647,6 +671,7 @@ mod tests { broadcaster.begin_explicit_broadcast().unwrap(), test_transaction(), Duration::from_secs(1), + None, ) .await, Err(TxBroadcastError::NotDispatched) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 01b54912e2..436a0e10b5 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -563,8 +563,24 @@ impl Wallet { Ok(()) } - pub(crate) fn end_broadcast_dispatch(&self, txid: &Txid) { - self.inflight_broadcast_txids.lock().unwrap().remove(txid); + 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( @@ -1514,7 +1530,8 @@ impl Wallet { .unwrap() .values() .any(|intent| intent.transactions.iter().any(|tx| tx.compute_txid() == txid)); - if belongs_to_intent { + 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]) @@ -4335,6 +4352,33 @@ mod tests { 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());