From 2749ef87aafe7dcaf555f6223a95c1b02de6f455 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 00:27:51 +0000 Subject: [PATCH 1/8] 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..176a7876c 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(()) + }) + .count(); + if count != 0 { + log_info!( + self.logger, + "Rebroadcast {} unconfirmed transactions on chain tip change", + count, + ); } }, WalletEvent::TxUnconfirmed { txid, tx, old_block_time: None } => { From 96cfe3b8c95ff3ab8b053d496e5b6bfb41ff4935 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 17:35:10 +0000 Subject: [PATCH 2/8] 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 | 185 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 7084135b0..01d237821 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -45,9 +45,192 @@ 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); + } + + #[test] + fn topological_sort_accepts_empty_vec() { + sort_parents_child_package_topologically(&mut []); + } +} From 88b1fa4c6bc42b51cba0a609b6c341d2d1e47ed4 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 18:16:49 +0000 Subject: [PATCH 3/8] 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/postgres-integration.yml | 13 ++++++--- .github/workflows/rust.yml | 19 ++++++++----- .github/workflows/vss-integration.yml | 15 +++++++++++ .github/workflows/vss-no-auth-integration.yml | 15 +++++++++++ scripts/build_electrs.sh | 27 +++++++++++++++++++ ...tcoind_electrs.sh => download_bitcoind.sh} | 19 +++---------- 8 files changed, 100 insertions(+), 34 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/postgres-integration.yml b/.github/workflows/postgres-integration.yml index 410136928..3764d454b 100644 --- a/.github/workflows/postgres-integration.yml +++ b/.github/workflows/postgres-integration.yml @@ -43,13 +43,18 @@ jobs: uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-esplora_a33e97e1-${{ 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..36196a0b3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -59,23 +59,30 @@ 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: | echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - name: Build on Rust ${{ matrix.toolchain }} - run: cargo build --verbose --color always + run: | + cargo build --verbose --color always - name: Build with UniFFI support on Rust ${{ matrix.toolchain }} if: matrix.build-uniffi - run: cargo build --features uniffi --verbose --color always + run: | + cargo build --features uniffi --verbose --color always - name: Check release build on Rust ${{ matrix.toolchain }} run: cargo check --release --verbose --color always - name: Check release build with UniFFI support on Rust ${{ matrix.toolchain }} diff --git a/.github/workflows/vss-integration.yml b/.github/workflows/vss-integration.yml index c67e9194e..a788644cd 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -30,6 +30,21 @@ jobs: uses: actions/checkout@v6 with: path: ldk-node + - 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 ./ldk-node/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 VSS uses: actions/checkout@v6 with: diff --git a/.github/workflows/vss-no-auth-integration.yml b/.github/workflows/vss-no-auth-integration.yml index 35666df03..5d81c1a44 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -30,6 +30,21 @@ jobs: uses: actions/checkout@v6 with: path: ldk-node + - 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 ./ldk-node/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 VSS uses: actions/checkout@v6 with: 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 da2a3e2d59401e4f3ae126620439f10215dbb3a3 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 15:40:30 +0000 Subject: [PATCH 4/8] Switch cln lnd and eclair interop tests to esplora The mempool/electrs docker image used in those tests only supports submitpackage via the esplora interface, not the electrum interface. We also bump the Bitcoin Core version used in kotlin and python tests to support ephemeral dust. --- tests/common/scenarios/mod.rs | 4 ++-- tests/docker/docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 3 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 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: [ From 8b8b814d5e597863a5129ec8ef2dc07bca4cbb47 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 01:28:46 +0000 Subject: [PATCH 5/8] 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 | 8 +++ src/chain/esplora.rs | 11 +++- src/chain/mod.rs | 50 ++++++++++++++--- src/config.rs | 3 +- 6 files changed, 188 insertions(+), 73 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 03ded494f..dc2499fa8 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1443,18 +1443,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()); @@ -1476,55 +1480,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..049fb009f 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -405,6 +405,14 @@ impl ElectrumRuntimeClient { Error::ConnectionFailed })?, ); + if config.anchor_channels_config.is_some() { + electrum_client.transaction_broadcast_package(&super::dummy_package()).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..898e963b8 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -41,7 +41,7 @@ pub(super) struct EsploraChainSource { } 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 +57,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(&super::dummy_package(), 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..c3625aaae 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -29,6 +29,37 @@ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, NodeMetrics}; +/// We use this parent-child TRUC package to make sure the configured chain source supports +/// broadcasting packages via the `submitpackage` Bitcoin Core RPC. +const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; +const PARENT_HEX: &str = + "0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000fd\ + ffffff0201000000000000000451024e73876100000000000022512042731375894dad3b25092cd0f713dc5bee4\ + a71e30a95e1db3d880906d7eba1fa01409327942924218e4eb1635a7cce6706fcb37b8bbb61a2f0b86357356681\ + 4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f115a97b0e00"; +const CHILD_TXID: &str = "d011b3ff78cdfb8b93822639ea87771847936b04bb83afc8763a7c02a386ae26"; +const CHILD_HEX: &str = + "0300000000010296f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0000000000ff\ + ffffff96f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0100000000fdffffff015\ + 660000000000000225120ac18cd599a1be003595854e2eeec18dbe1c92d04b0ba05812d04445e3fcf16bc000140\ + 1462a35808d77a164f0a23a84c4721d1545befd09ad19945bb8aa0ea5576953a9699038725f944b1bc429942ef4\ + 7e6504a554babf022cb15db53be2d8c1dbfe5a97b0e00"; + +fn dummy_package() -> [bitcoin::Transaction; 2] { + use bitcoin::consensus::Decodable; + use bitcoin::hex::FromHex; + use bitcoin::Transaction; + let parent_tx_bytes = Vec::from_hex(PARENT_HEX).expect("read from a constant"); + let child_tx_bytes = Vec::from_hex(CHILD_HEX).expect("read from a constant"); + let parent = + Transaction::consensus_decode(&mut &parent_tx_bytes[..]).expect("read from a constant"); + let child = + Transaction::consensus_decode(&mut &child_tx_bytes[..]).expect("read from a constant"); + assert_eq!(parent.compute_txid().to_string(), PARENT_TXID); + assert_eq!(child.compute_txid().to_string(), CHILD_TXID); + [parent, child] +} + pub(crate) enum WalletSyncStatus { Completed, InProgress { subscribers: tokio::sync::broadcast::Sender> }, @@ -96,7 +127,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 +142,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 +174,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 +185,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 +198,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 +210,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> { diff --git a/src/config.rs b/src/config.rs index 558a4d061..0402ebb46 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,7 +54,8 @@ pub const DEFAULT_LOG_FILENAME: &'static str = "ldk_node.log"; /// The default storage directory. pub const DEFAULT_STORAGE_DIR_PATH: &str = "/tmp/ldk_node"; -// The default Esplora server we're using. +// The default Esplora server we're using. It supports `submitpackage`, check using POST on the +// `/txs/package` endpoint. pub(crate) const DEFAULT_ESPLORA_SERVER_URL: &str = "https://blockstream.info/api"; // The 'stop gap' parameter used by BDK's wallet sync. This seems to configure the threshold From 3d36f685c8de263d3356eece036ba84a0388a3d8 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 24 Oct 2025 06:01:26 +0000 Subject: [PATCH 6/8] 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 049fb009f..8d321fe0d 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 } } } @@ -551,9 +553,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!( @@ -579,6 +589,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 898e963b8..4cb0b7d9f 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -362,7 +362,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), @@ -386,6 +386,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, @@ -393,6 +394,7 @@ impl EsploraChainSource { status, message ); + log_error!(self.logger, "Failed to broadcast transaction {}", txid,); } log_trace!( self.logger, @@ -429,6 +431,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.eq_ignore_ascii_case("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 9537b273a79097868aeebdce44ad06d7a941c935 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 29 Oct 2025 07:00:04 +0000 Subject: [PATCH 7/8] 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 7d23be99a..f6dcc518a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1256,7 +1256,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 614be098b..9f7f4d592 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1276,7 +1276,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)) } @@ -2204,9 +2205,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 3cd6d110d..9128b70e5 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -761,9 +761,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 4e72af0015b4dd6649bcc597cd29f8bfed464e40 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 13 Oct 2025 13:11:21 +0000 Subject: [PATCH 8/8] Negotiate 0FC channels if the anchor config is set --- src/config.rs | 16 ++++++++++------ tests/common/mod.rs | 7 +++---- tests/integration_tests_rust.rs | 14 +++----------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0402ebb46..0b4323208 100644 --- a/src/config.rs +++ b/src/config.rs @@ -171,15 +171,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, @@ -282,7 +284,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 @@ -404,6 +406,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 30d9a4387..2cebe86fa 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1408,10 +1408,9 @@ pub(crate) async fn do_channel_full_cycle( let node_a_outbound_capacity_msat = node_a.list_channels()[0].outbound_capacity_msat; let node_a_reserve_msat = node_a.list_channels()[0].unspendable_punishment_reserve.unwrap() * 1000; - // 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 }; + // If we expect an anchor channel, this will be a 0FC channel, so no funds will be + // allocated to the anchor. + let node_a_anchors_msat = 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 1ea6c4584..950feb14e 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 ); }