From 3b2ef69ad3fd639f5479043c529beb0f2682f003 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 00:27:51 +0000 Subject: [PATCH 01/10] Only pass TRUC packages as multi-transaction vecs `BroadcasterInterface::broadcast_transactions` requires that any passed vector containing multiple transactions must be a single child together with its parents. We will lean on this contract in upcoming commits, so here we fix a case where we broke this contract. --- src/wallet/mod.rs | 45 +++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 13b1f384f..97be9b4b3 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -324,32 +324,25 @@ impl Wallet { } } - if !unconfirmed_outbound_txids.is_empty() { - let txs_to_broadcast: Vec = unconfirmed_outbound_txids - .iter() - .filter_map(|txid| { - locked_wallet.tx_details(*txid).map(|d| (*d.tx).clone()) - }) - .collect(); - - if !txs_to_broadcast.is_empty() { - let tx_refs: Vec<( - &Transaction, - lightning::chain::chaininterface::TransactionType, - )> = - txs_to_broadcast - .iter() - .map(|tx| { - (tx, lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }) - }) - .collect(); - self.broadcaster.broadcast_transactions(&tx_refs); - log_info!( - self.logger, - "Rebroadcast {} unconfirmed transactions on chain tip change", - txs_to_broadcast.len() - ); - } + let count: usize = unconfirmed_outbound_txids + .into_iter() + .filter_map(|txid| { + let tx = locked_wallet.tx_details(txid).map(|d| d.tx)?; + let transaction_type = + lightning::chain::chaininterface::TransactionType::Sweep { + channels: vec![], + }; + self.broadcaster + .broadcast_transactions(&[(tx.as_ref(), transaction_type)]); + Some(1) + }) + .sum(); + if count != 0 { + log_info!( + self.logger, + "Rebroadcast {} unconfirmed transactions on chain tip change", + count, + ); } }, WalletEvent::TxUnconfirmed { txid, tx, old_block_time: None } => { From 3d097b00e8f4b8b937564abfb9bda91963c2a1bb Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 17:35:10 +0000 Subject: [PATCH 02/10] Sort packages received via `BroadcasterInterface` Implementations of `BroadcasterInterface` cannot assume any topological ordering on the transactions received, so here we order the received transactions before adding them to the broadcast queue. Any consumers of the queue can now assume all transactions received to be topologically sorted. Codex wrote the tests. --- src/tx_broadcaster.rs | 180 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 7084135b0..285d83790 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -45,9 +45,187 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - let package = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); + let mut package = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); + sort_parents_child_package_topologically(&mut package); self.queue_sender.try_send(package).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } } + +fn sort_parents_child_package_topologically(txs: &mut [Transaction]) { + // LDK multi-transaction broadcasts are one child plus its direct parents, and the + // child spends every parent. Thus, checking adjacent pairs is enough to find the + // child, while the already-sorted common case exits after only hashing one transaction. + if txs.len() < 2 { + return; + } + let mut child_pos = txs.len() - 1; + let mut pos = txs.len() - 1; + 'outer: while pos > 0 { + let txid_a = txs[pos - 1].compute_txid(); + for txid in txs[pos].input.iter().map(|input| input.previous_output.txid) { + if txid == txid_a { + child_pos = pos; + break 'outer; + } + } + let txid_b = txs[pos].compute_txid(); + for txid in txs[pos - 1].input.iter().map(|input| input.previous_output.txid) { + if txid == txid_b { + child_pos = pos - 1; + break 'outer; + } + } + if pos == 2 { + pos = 1; + } else { + pos = pos.saturating_sub(2); + } + } + debug_assert!(pos != 0); + txs.swap(child_pos, txs.len() - 1); +} + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; + + use super::sort_parents_child_package_topologically; + + fn txin(txid: Txid, vout: u32) -> TxIn { + TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + } + } + + fn txout(value_sat: u64) -> TxOut { + TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() } + } + + fn parent_tx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([seed; 32]), 0)], + output: vec![txout(1_000 + u64::from(seed))], + } + } + + fn child_tx(parents: &[&Transaction]) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: parents + .iter() + .enumerate() + .map(|(idx, parent)| txin(parent.compute_txid(), idx as u32)) + .collect(), + output: vec![txout(1_000)], + } + } + + fn assert_parents_before_child( + txs: &[Transaction], expected_child: Txid, expected_parents: &[Txid], + ) { + assert_eq!(txs.last().map(Transaction::compute_txid), Some(expected_child)); + assert_eq!(txs.len(), expected_parents.len() + 1); + + let parent_txids = + txs[..txs.len() - 1].iter().map(Transaction::compute_txid).collect::>(); + for expected_parent in expected_parents { + assert!(parent_txids.contains(expected_parent)); + } + } + + #[test] + fn topological_sort_leaves_sorted_package_unchanged() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + + let original_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), child.compute_txid()]; + let mut package = vec![parent_a, parent_b, child]; + + sort_parents_child_package_topologically(&mut package); + + assert_eq!( + package.iter().map(Transaction::compute_txid).collect::>(), + original_txids + ); + } + + #[test] + fn topological_sort_moves_single_parent_child_from_front_to_end() { + let parent = parent_tx(1); + let child = child_tx(&[&parent]); + let parent_txids = [parent.compute_txid()]; + let child_txid = child.compute_txid(); + let mut package = vec![child, parent]; + + sort_parents_child_package_topologically(&mut package); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let mut package = vec![child, parent_a, parent_b]; + + sort_parents_child_package_topologically(&mut package); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_with_multiple_parents_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let parent_c = parent_tx(3); + let child = child_tx(&[&parent_a, &parent_b, &parent_c]); + let parent_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), parent_c.compute_txid()]; + let child_txid = child.compute_txid(); + let mut package = vec![child, parent_a, parent_b, parent_c]; + + sort_parents_child_package_topologically(&mut package); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_middle_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let mut package = vec![parent_a, child, parent_b]; + + sort_parents_child_package_topologically(&mut package); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_leaves_single_transaction_package_unchanged() { + let parent = parent_tx(1); + let parent_txid = parent.compute_txid(); + let mut package = vec![parent]; + + sort_parents_child_package_topologically(&mut package); + + assert_eq!(package.len(), 1); + assert_eq!(package[0].compute_txid(), parent_txid); + } +} From 5a8f520f505b38492853573141ebed40e968fef9 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 19:19:06 +0000 Subject: [PATCH 03/10] Use a patched blockstream-electrs in CI The patch adds support for the `broadcast_package` method added in electrum protocol v1.6. Upcoming commits will require this patch to pass CI. --- .github/workflows/benchmarks.yml | 13 ++++++--- .github/workflows/hrn-integration.yml | 13 ++++++--- .github/workflows/rust.yml | 13 ++++++--- scripts/build_electrs.sh | 27 +++++++++++++++++++ ...tcoind_electrs.sh => download_bitcoind.sh} | 19 +++---------- 5 files changed, 57 insertions(+), 28 deletions(-) create mode 100755 scripts/build_electrs.sh rename scripts/{download_bitcoind_electrs.sh => download_bitcoind.sh} (55%) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 6d0056e9a..1ff1b0b26 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -29,13 +29,18 @@ jobs: uses: actions/cache@v5 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "(steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "steps.cache-bitcoind.outputs.cache-hit != 'true'" run: | - source ./scripts/download_bitcoind_electrs.sh + source ./scripts/download_bitcoind.sh mkdir -p bin mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} - name: Set bitcoind/electrs environment variables run: | diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml index f7ded7bc5..466886eb4 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -27,13 +27,18 @@ jobs: uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true'" + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "steps.cache-bitcoind.outputs.cache-hit != 'true'" run: | - source ./scripts/download_bitcoind_electrs.sh + source ./scripts/download_bitcoind.sh mkdir -p bin mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} - name: Set bitcoind/electrs environment variables run: | diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b2575aca1..7c685355c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -59,13 +59,18 @@ jobs: uses: actions/cache@v5 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "matrix.platform != 'windows-latest' && (steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "matrix.platform != 'windows-latest' && steps.cache-bitcoind.outputs.cache-hit != 'true'" run: | - source ./scripts/download_bitcoind_electrs.sh + source ./scripts/download_bitcoind.sh mkdir -p bin mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "matrix.platform != 'windows-latest' && steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} - name: Set bitcoind/electrs environment variables run: | diff --git a/scripts/build_electrs.sh b/scripts/build_electrs.sh new file mode 100755 index 000000000..1300e87fe --- /dev/null +++ b/scripts/build_electrs.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -eox pipefail + +# Our Esplora-based tests require `electrs` binaries. Here, we +# download the code, build the binaries, and export their location +# via `ELECTRS_EXE`/`BITCOIND_EXE` which will be used by the +# `electrsd`/`bitcoind` crates in our tests. + +HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" +ELECTRS_GIT_REPO="https://github.com/tankyleo/blockstream-electrs.git" +ELECTRS_TAG="2026-05-26-electrum-submit-package" +if [[ "$HOST_PLATFORM" != *linux* && "$HOST_PLATFORM" != *darwin* ]]; then + printf "\n\n" + echo "Unsupported platform: $HOST_PLATFORM Exiting.." + exit 1 +fi + +DL_TMP_DIR=$(mktemp -d) +trap 'rm -rf -- "$DL_TMP_DIR"' EXIT + +pushd "$DL_TMP_DIR" +git clone --branch $ELECTRS_TAG --depth 1 $ELECTRS_GIT_REPO blockstream-electrs +cd blockstream-electrs +RUSTFLAGS="" cargo build +export ELECTRS_EXE="$DL_TMP_DIR"/blockstream-electrs/target/debug/electrs +chmod +x "$ELECTRS_EXE" +popd diff --git a/scripts/download_bitcoind_electrs.sh b/scripts/download_bitcoind.sh similarity index 55% rename from scripts/download_bitcoind_electrs.sh rename to scripts/download_bitcoind.sh index f94e280e3..102cf826f 100755 --- a/scripts/download_bitcoind_electrs.sh +++ b/scripts/download_bitcoind.sh @@ -1,24 +1,18 @@ #!/bin/bash set -eox pipefail -# Our Esplora-based tests require `electrs` and `bitcoind` -# binaries. Here, we download the binaries, validate them, and export their -# location via `ELECTRS_EXE`/`BITCOIND_EXE` which will be used by the +# Our Esplora-based tests require `bitcoind` binaries. Here, we +# download the binaries, validate them, and export their location +# via `ELECTRS_EXE`/`BITCOIND_EXE` which will be used by the # `electrsd`/`bitcoind` crates in our tests. HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" -ELECTRS_DL_ENDPOINT="https://github.com/RCasatta/electrsd/releases/download/electrs_releases" -ELECTRS_VERSION="esplora_a33e97e1a1fc63fa9c20a116bb92579bbf43b254" BITCOIND_DL_ENDPOINT="https://bitcoincore.org/bin/" BITCOIND_VERSION="29.0" if [[ "$HOST_PLATFORM" == *linux* ]]; then - ELECTRS_DL_FILE_NAME=electrs_linux_"$ELECTRS_VERSION".zip - ELECTRS_DL_HASH="865e26a96e8df77df01d96f2f569dcf9622fc87a8d99a9b8fe30861a4db9ddf1" BITCOIND_DL_FILE_NAME=bitcoin-"$BITCOIND_VERSION"-x86_64-linux-gnu.tar.gz BITCOIND_DL_HASH="a681e4f6ce524c338a105f214613605bac6c33d58c31dc5135bbc02bc458bb6c" elif [[ "$HOST_PLATFORM" == *darwin* ]]; then - ELECTRS_DL_FILE_NAME=electrs_macos_"$ELECTRS_VERSION".zip - ELECTRS_DL_HASH="2d5ff149e8a2482d3658e9b386830dfc40c8fbd7c175ca7cbac58240a9505bcd" BITCOIND_DL_FILE_NAME=bitcoin-"$BITCOIND_VERSION"-x86_64-apple-darwin.tar.gz BITCOIND_DL_HASH="5bb824fc86a15318d6a83a1b821ff4cd4b3d3d0e1ec3d162b805ccf7cae6fca8" else @@ -31,13 +25,6 @@ DL_TMP_DIR=$(mktemp -d) trap 'rm -rf -- "$DL_TMP_DIR"' EXIT pushd "$DL_TMP_DIR" -ELECTRS_DL_URL="$ELECTRS_DL_ENDPOINT"/"$ELECTRS_DL_FILE_NAME" -curl -L -o "$ELECTRS_DL_FILE_NAME" "$ELECTRS_DL_URL" -echo "$ELECTRS_DL_HASH $ELECTRS_DL_FILE_NAME"|shasum -a 256 -c -unzip "$ELECTRS_DL_FILE_NAME" -export ELECTRS_EXE="$DL_TMP_DIR"/electrs -chmod +x "$ELECTRS_EXE" - BITCOIND_DL_URL="$BITCOIND_DL_ENDPOINT"/bitcoin-core-"$BITCOIND_VERSION"/"$BITCOIND_DL_FILE_NAME" curl -L -o "$BITCOIND_DL_FILE_NAME" "$BITCOIND_DL_URL" echo "$BITCOIND_DL_HASH $BITCOIND_DL_FILE_NAME"|shasum -a 256 -c From c07dc2910497e0fab6e9d6bf4bb899f3cea78f27 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 01:28:46 +0000 Subject: [PATCH 04/10] Check that the backend supports `submitpackage` Do this as early as possible during startup, only if `anchor_channels_config` is set. --- src/builder.rs | 122 +++++++++++++++++++++++------------------- src/chain/bitcoind.rs | 67 ++++++++++++++++++++--- src/chain/electrum.rs | 28 ++++++++++ src/chain/esplora.rs | 33 +++++++++++- src/chain/mod.rs | 19 ++++--- 5 files changed, 197 insertions(+), 72 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 5f616c4ce..be0fd1b74 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1337,18 +1337,22 @@ fn build_with_store_internal( let (chain_source, chain_tip_opt) = match chain_data_source_config { Some(ChainDataSourceConfig::Esplora { server_url, headers, sync_config }) => { let sync_config = sync_config.unwrap_or(EsploraSyncConfig::default()); - ChainSource::new_esplora( - server_url.clone(), - headers.clone(), - sync_config, - Arc::clone(&fee_estimator), - Arc::clone(&tx_broadcaster), - Arc::clone(&kv_store), - Arc::clone(&config), - Arc::clone(&logger), - Arc::clone(&node_metrics), - ) - .map_err(|()| BuildError::ChainSourceSetupFailed)? + runtime + .block_on(async { + ChainSource::new_esplora( + server_url.clone(), + headers.clone(), + sync_config, + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + ) + .await + }) + .map_err(|()| BuildError::ChainSourceSetupFailed)? }, Some(ChainDataSourceConfig::Electrum { server_url, sync_config }) => { let sync_config = sync_config.unwrap_or(ElectrumSyncConfig::default()); @@ -1370,55 +1374,63 @@ fn build_with_store_internal( rpc_password, rest_client_config, }) => match rest_client_config { - Some(rest_client_config) => runtime.block_on(async { - ChainSource::new_bitcoind_rest( - rpc_host.clone(), - *rpc_port, - rpc_user.clone(), - rpc_password.clone(), - Arc::clone(&fee_estimator), - Arc::clone(&tx_broadcaster), - Arc::clone(&kv_store), - Arc::clone(&config), - rest_client_config.clone(), - Arc::clone(&logger), - Arc::clone(&node_metrics), - ) - .await - }), - None => runtime.block_on(async { - ChainSource::new_bitcoind_rpc( - rpc_host.clone(), - *rpc_port, - rpc_user.clone(), - rpc_password.clone(), - Arc::clone(&fee_estimator), - Arc::clone(&tx_broadcaster), - Arc::clone(&kv_store), - Arc::clone(&config), - Arc::clone(&logger), - Arc::clone(&node_metrics), - ) - .await - }), + Some(rest_client_config) => runtime + .block_on(async { + ChainSource::new_bitcoind_rest( + rpc_host.clone(), + *rpc_port, + rpc_user.clone(), + rpc_password.clone(), + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + rest_client_config.clone(), + Arc::clone(&logger), + Arc::clone(&node_metrics), + ) + .await + }) + .map_err(|()| BuildError::ChainSourceSetupFailed)?, + None => runtime + .block_on(async { + ChainSource::new_bitcoind_rpc( + rpc_host.clone(), + *rpc_port, + rpc_user.clone(), + rpc_password.clone(), + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + ) + .await + }) + .map_err(|()| BuildError::ChainSourceSetupFailed)?, }, None => { // Default to Esplora client. let server_url = DEFAULT_ESPLORA_SERVER_URL.to_string(); let sync_config = EsploraSyncConfig::default(); - ChainSource::new_esplora( - server_url.clone(), - HashMap::new(), - sync_config, - Arc::clone(&fee_estimator), - Arc::clone(&tx_broadcaster), - Arc::clone(&kv_store), - Arc::clone(&config), - Arc::clone(&logger), - Arc::clone(&node_metrics), - ) - .map_err(|()| BuildError::ChainSourceSetupFailed)? + runtime + .block_on(async { + ChainSource::new_esplora( + server_url.clone(), + HashMap::new(), + sync_config, + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + ) + .await + }) + .map_err(|()| BuildError::ChainSourceSetupFailed)? }, }; let chain_source = Arc::new(chain_source); diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 2582f32f6..1bc85b061 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -59,11 +59,11 @@ pub(super) struct BitcoindChainSource { } impl BitcoindChainSource { - pub(crate) fn new_rpc( + pub(crate) async fn new_rpc( rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, fee_estimator: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc>, - ) -> Self { + ) -> Result { let api_client = Arc::new(BitcoindClient::new_rpc( rpc_host.clone(), rpc_port.clone(), @@ -71,9 +71,22 @@ impl BitcoindChainSource { rpc_password.clone(), )); + let node_version = api_client.get_node_version().await.map_err(|e| { + log_error!(logger, "Failed to get node version: {:?}", e); + })?; + + if config.anchor_channels_config.is_some() { + // v26 first shipped the `submitpackage` RPC, but we need v29 to relay ephemeral + // dust + if node_version < 290000 { + log_error!(logger, "Bitcoin backend MUST be greater than or equal to v29"); + return Err(()); + } + } + let latest_chain_tip = RwLock::new(None); let wallet_polling_status = Mutex::new(WalletSyncStatus::Completed); - Self { + Ok(Self { api_client, latest_chain_tip, wallet_polling_status, @@ -82,15 +95,15 @@ impl BitcoindChainSource { config, logger: Arc::clone(&logger), node_metrics, - } + }) } - pub(crate) fn new_rest( + pub(crate) async fn new_rest( rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, fee_estimator: Arc, kv_store: Arc, config: Arc, rest_client_config: BitcoindRestClientConfig, logger: Arc, node_metrics: Arc>, - ) -> Self { + ) -> Result { let api_client = Arc::new(BitcoindClient::new_rest( rest_client_config.rest_host, rest_client_config.rest_port, @@ -100,10 +113,23 @@ impl BitcoindChainSource { rpc_password, )); + let node_version = api_client.get_node_version().await.map_err(|e| { + log_error!(logger, "Failed to get node version: {:?}", e); + })?; + + if config.anchor_channels_config.is_some() { + // v26 first shipped the `submitpackage` RPC, but we need v29 to relay ephemeral + // dust + if node_version < 290000 { + log_error!(logger, "Bitcoin backend MUST be greater than or equal to v29"); + return Err(()); + } + } + let latest_chain_tip = RwLock::new(None); let wallet_polling_status = Mutex::new(WalletSyncStatus::Completed); - Self { + Ok(Self { api_client, latest_chain_tip, wallet_polling_status, @@ -112,7 +138,7 @@ impl BitcoindChainSource { config, logger: Arc::clone(&logger), node_metrics, - } + }) } pub(super) fn as_utxo_source(&self) -> UtxoSourceClient { @@ -745,6 +771,31 @@ impl BitcoindClient { } } + pub(crate) async fn get_node_version(&self) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support `getnetworkinfo` + // so we use the RPC client. + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn get_node_version_inner(rpc_client: Arc) -> Result { + rpc_client.call_method::("getnetworkinfo", &[]).await.and_then(|value| { + value["version"].as_u64().ok_or(RpcClientError::InvalidData(String::from( + "The version field in the `getnetworkinfo` response should be a u64", + ))) + }) + } + /// Broadcasts the provided transaction. pub(crate) async fn broadcast_transaction( &self, tx: &Transaction, diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 54e7fff0c..e6404cbd0 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -387,6 +387,28 @@ struct ElectrumRuntimeClient { logger: Arc, } +fn dummy_transaction() -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: bitcoin::Txid::from_raw_hash(bitcoin::hashes::Hash::from_byte_array( + [0u8; 32], + )), + vout: 0, + }, + sequence: bitcoin::Sequence::MAX, + script_sig: bitcoin::ScriptBuf::new(), + witness: bitcoin::Witness::new(), + }], + output: vec![bitcoin::TxOut { + script_pubkey: bitcoin::ScriptBuf::new(), + value: bitcoin::Amount::ZERO, + }], + } +} + impl ElectrumRuntimeClient { fn new( server_url: String, sync_config: ElectrumSyncConfig, runtime: Arc, @@ -405,6 +427,12 @@ impl ElectrumRuntimeClient { Error::ConnectionFailed })?, ); + if config.anchor_channels_config.is_some() { + electrum_client.transaction_broadcast_package(&[dummy_transaction()]).map_err(|e| { + log_error!(logger, "Electrum server does not support submit package: {:?}", e); + Error::ConnectionFailed + })?; + } let bdk_electrum_client = Arc::new(BdkElectrumClient::new(Arc::clone(&electrum_client))); let tx_sync = Arc::new( ElectrumSyncClient::new(server_url.clone(), Arc::clone(&logger)).map_err(|e| { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 5825a0984..269bd58f2 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -40,8 +40,30 @@ pub(super) struct EsploraChainSource { node_metrics: Arc>, } +fn dummy_transaction() -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { + txid: bitcoin::Txid::from_raw_hash(bitcoin::hashes::Hash::from_byte_array( + [0u8; 32], + )), + vout: 0, + }, + sequence: bitcoin::Sequence::MAX, + script_sig: bitcoin::ScriptBuf::new(), + witness: bitcoin::Witness::new(), + }], + output: vec![bitcoin::TxOut { + script_pubkey: bitcoin::ScriptBuf::new(), + value: bitcoin::Amount::ZERO, + }], + } +} + impl EsploraChainSource { - pub(crate) fn new( + pub(crate) async fn new( server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, fee_estimator: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc>, @@ -57,6 +79,15 @@ impl EsploraChainSource { let esplora_client = client_builder.build_async().map_err(|e| { log_error!(logger, "Failed to build Esplora client: {}", e); })?; + + if config.anchor_channels_config.is_some() { + esplora_client.submit_package(&[dummy_transaction()], None, None).await.map_err( + |e| { + log_error!(logger, "Esplora server does not support submit package: {:?}", e); + }, + )?; + } + let tx_sync = Arc::new(EsploraSyncClient::from_client(esplora_client.clone(), Arc::clone(&logger))); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index cb8541be6..add86d2d8 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -96,7 +96,7 @@ enum ChainSourceKind { } impl ChainSource { - pub(crate) fn new_esplora( + pub(crate) async fn new_esplora( server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: Arc, @@ -111,7 +111,8 @@ impl ChainSource { config, Arc::clone(&logger), node_metrics, - )?; + ) + .await?; let kind = ChainSourceKind::Esplora(esplora_chain_source); let registered_txids = Mutex::new(Vec::new()); Ok((Self { kind, registered_txids, tx_broadcaster, logger }, None)) @@ -142,7 +143,7 @@ impl ChainSource { fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc>, - ) -> (Self, Option) { + ) -> Result<(Self, Option), ()> { let bitcoind_chain_source = BitcoindChainSource::new_rpc( rpc_host, rpc_port, @@ -153,11 +154,12 @@ impl ChainSource { config, Arc::clone(&logger), node_metrics, - ); + ) + .await?; let best_block = bitcoind_chain_source.poll_best_block().await.ok(); let kind = ChainSourceKind::Bitcoind(bitcoind_chain_source); let registered_txids = Mutex::new(Vec::new()); - (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) + Ok((Self { kind, registered_txids, tx_broadcaster, logger }, best_block)) } pub(crate) async fn new_bitcoind_rest( @@ -165,7 +167,7 @@ impl ChainSource { fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, rest_client_config: BitcoindRestClientConfig, logger: Arc, node_metrics: Arc>, - ) -> (Self, Option) { + ) -> Result<(Self, Option), ()> { let bitcoind_chain_source = BitcoindChainSource::new_rest( rpc_host, rpc_port, @@ -177,11 +179,12 @@ impl ChainSource { rest_client_config, Arc::clone(&logger), node_metrics, - ); + ) + .await?; let best_block = bitcoind_chain_source.poll_best_block().await.ok(); let kind = ChainSourceKind::Bitcoind(bitcoind_chain_source); let registered_txids = Mutex::new(Vec::new()); - (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) + Ok((Self { kind, registered_txids, tx_broadcaster, logger }, best_block)) } pub(crate) fn start(&self, runtime: Arc) -> Result<(), Error> { From 6da85e844a184cda4e4e7fa8b49d8d3f99f985a1 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 24 Oct 2025 06:01:26 +0000 Subject: [PATCH 05/10] Submit TRUC packages via all chain sources We rely on the `BroadcasterInterface` contract whereby any multi-transaction vector must be a single child and its parents, and must be broadcasted together as a package using `submitpackage`. In a prior commit, we added the guarantee that any packages received from the broadcast queue are already topologically sorted, and hence can be passed directly to the `submit_package` Bitcoin Core RPC. --- src/chain/bitcoind.rs | 81 ++++++++++++++++++++++++++++++++++++++++--- src/chain/electrum.rs | 77 +++++++++++++++++++++++++++++++++++++--- src/chain/esplora.rs | 79 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 227 insertions(+), 10 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 1bc85b061..ff0f94f07 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -595,11 +595,8 @@ impl BitcoindChainSource { } pub(crate) async fn process_broadcast_package(&self, package: Vec) { - // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 - // features, we should eventually switch to use `submitpackage` via the - // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual - // transactions. - for tx in &package { + if package.len() == 1 { + let tx = &package[0]; let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), @@ -634,6 +631,48 @@ impl BitcoindChainSource { ); }, } + } else if package.len() > 1 { + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.submit_package(&package), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + if result.contains(r#""package_msg":"success""#) { + log_trace!(self.logger, "Successfully broadcast package {:?}", txids); + log_trace!(self.logger, "Successfully broadcast package {}", result); + } else { + log_error!(self.logger, "Failed to broadcast package {:?}", txids); + log_trace!(self.logger, "Failed to broadcast package {}", result); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + }, + Err(e) => { + log_error!(self.logger, "Failed to broadcast package {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + }, + Err(e) => { + log_error!( + self.logger, + "Failed to broadcast package due to timeout {:?}: {}", + txids, + e + ); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + } } } } @@ -824,6 +863,38 @@ impl BitcoindClient { rpc_client.call_method::("sendrawtransaction", &[tx_json]).await } + /// Submits the provided package + pub(crate) async fn submit_package( + &self, package: &[Transaction], + ) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support submitting packages + // so we use the RPC client. + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn submit_package_inner( + rpc_client: Arc, package: &[Transaction], + ) -> Result { + let package_serialized: Vec<_> = + package.iter().map(|tx| bitcoin::consensus::encode::serialize_hex(tx)).collect(); + let package_json = serde_json::json!(package_serialized); + rpc_client + .call_method::("submitpackage", &[package_json]) + .await + .map(|value| value.to_string()) + } + /// Retrieve the fee estimate needed for a transaction to begin /// confirmation within the provided `num_blocks`. pub(crate) async fn get_fee_estimate_for_target( diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index e6404cbd0..bc425089a 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -275,7 +275,7 @@ impl ElectrumChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_broadcast_package(&self, mut package: Vec) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() { @@ -285,8 +285,10 @@ impl ElectrumChainSource { return; }; - for tx in package { - electrum_client.broadcast(tx).await; + if package.len() == 1 { + electrum_client.broadcast(package.pop().expect("Package length is 1")).await + } else if package.len() > 1 { + electrum_client.submit_package(package).await } } } @@ -571,9 +573,17 @@ impl ElectrumRuntimeClient { match timeout_fut.await { Ok(res) => match res { - Ok(_) => { + Ok(Ok(txid)) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, + Ok(Err(e)) => { + log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); + log_trace!( + self.logger, + "Failed broadcast transaction bytes: {}", + log_bytes!(tx_bytes) + ); + }, Err(e) => { log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); log_trace!( @@ -599,6 +609,65 @@ impl ElectrumRuntimeClient { } } + async fn submit_package(&self, package: Vec) { + let electrum_client = Arc::clone(&self.electrum_client); + + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + let cloned_package = package.clone(); + + let spawn_fut = self + .runtime + .spawn_blocking(move || electrum_client.transaction_broadcast_package(&cloned_package)); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + spawn_fut, + ); + + match timeout_fut.await { + Ok(res) => match res { + Ok(Ok(result)) => { + if result.success { + log_trace!(self.logger, "Successfully broadcast package {:?}", txids); + log_trace!(self.logger, "Successfully broadcast package {:?}", result); + } else { + log_error!(self.logger, "Failed to broadcast package {:?}", txids); + log_trace!(self.logger, "Failed to broadcast package {:?}", result); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + }, + Ok(Err(e)) => { + log_error!(self.logger, "Failed to broadcast package {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast package bytes:",); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + Err(e) => { + log_error!(self.logger, "Failed to broadcast package {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast package bytes:",); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + }, + Err(e) => { + log_error!( + self.logger, + "Failed to broadcast package due to timeout {:?}: {}", + txids, + e + ); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + } + } + async fn get_fee_rate_cache_update( &self, ) -> Result, Error> { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 269bd58f2..43d482733 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -384,7 +384,7 @@ impl EsploraChainSource { } pub(crate) async fn process_broadcast_package(&self, package: Vec) { - for tx in &package { + if let [tx] = &package[..] { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), @@ -408,6 +408,7 @@ impl EsploraChainSource { "Failed to broadcast due to HTTP connection error: {}", message ); + log_trace!(self.logger, "Failed to broadcast transaction {}", txid,); } else { log_error!( self.logger, @@ -415,6 +416,7 @@ impl EsploraChainSource { status, message ); + log_error!(self.logger, "Failed to broadcast transaction {}", txid,); } log_trace!( self.logger, @@ -451,6 +453,81 @@ impl EsploraChainSource { ); }, } + } else if package.len() > 1 { + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + self.esplora_client.submit_package(&package, None, None), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + if result.package_msg == "success" { + log_trace!(self.logger, "Successfully broadcast package {:?}", txids); + log_trace!(self.logger, "Successfully broadcast package {:?}", result); + } else { + log_error!(self.logger, "Failed to broadcast package {:?}", txids); + log_trace!(self.logger, "Failed to broadcast package {:?}", result); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + }, + Err(e) => match e { + esplora_client::Error::HttpResponse { status, message } => { + if status == 400 { + // Log 400 at lesser level, as this often just means bitcoind already knows the + // transaction. + // FIXME: We can further differentiate here based on the error + // message which will be available with rust-esplora-client 0.7 and + // later. + log_trace!( + self.logger, + "Failed to broadcast due to HTTP connection error: {}", + message + ); + } else { + log_error!( + self.logger, + "Failed to broadcast due to HTTP connection error: {} - {}", + status, + message + ); + } + log_error!(self.logger, "Failed to broadcast package {:?}", txids); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + _ => { + log_error!( + self.logger, + "Failed to broadcast package {:?}: {}", + txids, + e + ); + log_trace!(self.logger, "Failed broadcast package bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + }, + }, + Err(e) => { + log_error!( + self.logger, + "Failed to broadcast package due to timeout {:?}: {}", + txids, + e + ); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in package { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + } } } } From ca5e9afcabc90dfa89bf7b3fc7573e07230ea1f0 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 29 Oct 2025 07:00:04 +0000 Subject: [PATCH 06/10] Include 0FC channels in anchor channel checks --- src/event.rs | 3 ++- src/lib.rs | 10 ++++++---- src/liquidity.rs | 4 +++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/event.rs b/src/event.rs index d672a90f6..939435adc 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1231,7 +1231,8 @@ where } } - let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx() + || channel_type.requires_anchor_zero_fee_commitments(); if anchor_channel && self.config.anchor_channels_config.is_none() { log_error!( self.logger, diff --git a/src/lib.rs b/src/lib.rs index 2d1a0d958..46850a66b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1266,7 +1266,8 @@ impl Node { .peer_by_node_id(peer_node_id) .ok_or(Error::ConnectionFailed)? .init_features; - let anchor_channel = init_features.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = init_features.requires_anchors_zero_fee_htlc_tx() + || init_features.requires_anchor_zero_fee_commitments(); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } @@ -2170,9 +2171,10 @@ pub(crate) fn total_anchor_channels_reserve_sats( !anchor_channels_config.trusted_peers_no_reserve.contains(&c.counterparty.node_id) && c.channel_shutdown_state .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) - && c.channel_type - .as_ref() - .map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) + && c.channel_type.as_ref().map_or(false, |t| { + t.requires_anchors_zero_fee_htlc_tx() + || t.requires_anchor_zero_fee_commitments() + }) }) .count() as u64 * anchor_channels_config.per_channel_reserve_sats diff --git a/src/liquidity.rs b/src/liquidity.rs index 66e3edd33..b2742a60a 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -758,9 +758,11 @@ where total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); + let anchor_channel = init_features.requires_anchors_zero_fee_htlc_tx() + || init_features.requires_anchor_zero_fee_commitments(); let required_funds_sats = channel_amount_sats + self.config.anchor_channels_config.as_ref().map_or(0, |c| { - if init_features.requires_anchors_zero_fee_htlc_tx() + if anchor_channel && !c.trusted_peers_no_reserve.contains(&their_network_key) { c.per_channel_reserve_sats From 1a519609052353e46f46df44295dbd6d6a13b2f5 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 13 Oct 2025 13:11:21 +0000 Subject: [PATCH 07/10] Negotiate 0FC channels if the anchor config is set --- src/config.rs | 16 ++++++++++------ tests/common/mod.rs | 2 +- tests/integration_tests_rust.rs | 14 +++----------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/config.rs b/src/config.rs index 558a4d061..5a620055b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -170,15 +170,17 @@ pub struct Config { /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, /// Configuration options pertaining to Anchor channels, i.e., channels for which the - /// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. + /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is + /// negotiated. /// /// Please refer to [`AnchorChannelsConfig`] for further information on Anchor channels. /// /// If set to `Some`, we'll try to open new channels with Anchors enabled, i.e., new channels - /// will be negotiated with the `option_anchors_zero_fee_htlc_tx` channel type if supported by - /// the counterparty. Note that this won't prevent us from opening non-Anchor channels if the - /// counterparty doesn't support `option_anchors_zero_fee_htlc_tx`. If set to `None`, new - /// channels will be negotiated with the legacy `option_static_remotekey` channel type only. + /// will be negotiated with the `option_zero_fee_commitments` channel type first, then the + /// `option_anchors_zero_fee_htlc_tx` channel type if supported by the counterparty. Note + /// that this won't prevent us from opening non-Anchor channels if the counterparty doesn't + /// support `option_anchors_zero_fee_htlc_tx`. If set to `None`, new channels will be + /// negotiated with the legacy `option_static_remotekey` channel type only. /// /// **Note:** If set to `None` *after* some Anchor channels have already been /// opened, no dedicated emergency on-chain reserve will be maintained for these channels, @@ -281,7 +283,7 @@ impl Default for HumanReadableNamesConfig { } /// Configuration options pertaining to 'Anchor' channels, i.e., channels for which the -/// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. +/// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is negotiated. /// /// Prior to the introduction of Anchor channels, the on-chain fees paying for the transactions /// issued on channel closure were pre-determined and locked-in at the time of the channel @@ -403,6 +405,8 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config.channel_handshake_limits.force_announced_channel_preference = false; user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = config.anchor_channels_config.is_some(); + user_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = + config.anchor_channels_config.is_some(); user_config.reject_inbound_splices = false; if may_announce_channel(config).is_err() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index b701028c5..c61bf64c0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1407,7 +1407,7 @@ pub(crate) async fn do_channel_full_cycle( // TODO: Zero-fee commitment channels are anchor channels, but do not allocate any // funds to the anchor, so this will need to be updated when we ship these channels // in ldk-node. - let node_a_anchors_msat = if expect_anchor_channel { 2 * 330 * 1000 } else { 0 }; + let node_a_anchors_msat = if expect_anchor_channel { 0 } else { 0 }; let funding_amount_msat = node_a.list_channels()[0].channel_value_sats * 1000; // Node B does not have any reserve, so we only subtract a few items on node A's // side to arrive at node B's capacity diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index a3970b1c2..942ca2ca2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1023,17 +1023,12 @@ async fn splice_channel() { let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); let opening_transaction_fee_sat = 156; - let closing_transaction_fee_sat = 614; - let anchor_output_sat = 330; assert_eq!( node_a.list_balances().total_onchain_balance_sats, premine_amount_sat - 4_000_000 - opening_transaction_fee_sat ); - assert_eq!( - node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - ); + assert_eq!(node_a.list_balances().total_lightning_balance_sats, 4_000_000); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 0); // Test that splicing and payments fail when there are insufficient funds @@ -1101,10 +1096,7 @@ async fn splice_channel() { // Mine a block to give time for the HTLC to resolve generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - assert_eq!( - node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 - ); + assert_eq!(node_a.list_balances().total_lightning_balance_sats, 4_000_000 + amount_msat / 1000); assert_eq!( node_b.list_balances().total_lightning_balance_sats, expected_splice_in_lightning_balance_sat - amount_msat / 1000 @@ -1138,7 +1130,7 @@ async fn splice_channel() { ); assert_eq!( node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - expected_splice_out_fee_sat + 4_000_000 - expected_splice_out_fee_sat ); } From 0e76c3ce4a55c7155c17c96b77eab8cd853e321b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 15:40:30 +0000 Subject: [PATCH 08/10] f: switch interop tests to use esplora, it supports submitpackage --- tests/common/scenarios/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index 7cbf56b8e..6c2564b76 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -92,10 +92,10 @@ pub(crate) async fn wait_for_htlcs_settled( pub(crate) fn setup_ldk_node() -> Node { let config = crate::common::random_config(true); let mut builder = ldk_node::Builder::from_config(config.node_config); - let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); + let mut sync_config = ldk_node::config::EsploraSyncConfig::default(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; sync_config.timeouts_config.lightning_wallet_sync_timeout_secs = 120; - builder.set_chain_source_electrum("tcp://127.0.0.1:50001".to_string(), Some(sync_config)); + builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), Some(sync_config)); let node = builder.build(config.node_entropy).unwrap(); node.start().unwrap(); node From 631cea99d69d91559a68bc8617e3d476d06cb5ac Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 16:04:30 +0000 Subject: [PATCH 09/10] f: Use submit package electrs in VSS CI --- .github/workflows/vss-integration.yml | 15 +++++++++++++++ .github/workflows/vss-no-auth-integration.yml | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/vss-integration.yml b/.github/workflows/vss-integration.yml index 959175162..cf3cdf3f7 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -26,6 +26,21 @@ jobs: --health-retries 5 steps: + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v5 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set electrs environment variable + run: | + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - name: Checkout code uses: actions/checkout@v6 with: diff --git a/.github/workflows/vss-no-auth-integration.yml b/.github/workflows/vss-no-auth-integration.yml index 950ff3e5f..48a410a20 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -26,6 +26,21 @@ jobs: --health-retries 5 steps: + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v5 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set electrs environment variable + run: | + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - name: Checkout code uses: actions/checkout@v6 with: From 6b83db912c3b42c2f0d96f6754e44d3b8ba599a1 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 16:20:22 +0000 Subject: [PATCH 10/10] f: bump python and kotlin to use Bitcoin Core v29 --- tests/docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml index e71fd70fb..5459e8eda 100644 --- a/tests/docker/docker-compose.yml +++ b/tests/docker/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: bitcoin: - image: blockstream/bitcoind:27.2 + image: blockstream/bitcoind:29.1 platform: linux/amd64 command: [