Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/eclair-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,19 @@ concurrency:

jobs:
check-eclair:
name: check-eclair (${{ matrix.name }})
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: standard
eclair_extra_java_opts: ""
rustflags: "--cfg eclair_test"
- name: zero-fee-commitments
eclair_extra_java_opts: "-Declair.features.zero_fee_commitments=optional"
rustflags: "--cfg eclair_test --cfg zero_fee_commitment_tests"
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down Expand Up @@ -37,6 +48,8 @@ jobs:
docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml exec bitcoin bitcoin-cli -regtest -rpcuser=user -rpcpassword=pass createwallet ldk_node_test

- name: Start Eclair
env:
ECLAIR_EXTRA_JAVA_OPTS: ${{ matrix.eclair_extra_java_opts }}
run: docker compose -p ldk-node -f tests/docker/docker-compose-eclair.yml up -d eclair

- name: Wait for Eclair to be ready
Expand All @@ -54,4 +67,5 @@ jobs:
exit 1

- name: Run Eclair integration tests
run: RUSTFLAGS="--cfg eclair_test" cargo test --test integration_tests_eclair -- --show-output --test-threads=1
run: |
RUSTFLAGS="${{ matrix.rustflags }}" cargo test --test integration_tests_eclair -- --show-output --test-threads=1
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set,
the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan
succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses.
- The `ChannelDetails` returned by `Node::list_channels` now exposes the negotiated
`ChannelTypeFeatures`.
- `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be
disabled. We still negotiate legacy channels if the peer does not support anchor channels.

Expand Down
42 changes: 31 additions & 11 deletions src/chain/electrum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ use bdk_wallet::{KeychainKind as BdkKeyChainKind, Update as BdkUpdate};
use bitcoin::transaction::Version;
use bitcoin::{FeeRate, Network, Script, ScriptBuf, Transaction, Txid};
use electrum_client::{
Batch, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, ElectrumApi,
Batch, BroadcastPackageRes, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder,
ElectrumApi,
};
use lightning::chain::{Confirm, Filter, WatchedOutput};
use lightning::util::ser::Writeable;
Expand Down Expand Up @@ -321,11 +322,6 @@ impl ElectrumChainSource {
return Err(Error::ConnectionFailed);
};

// TODO: Use `protocol_version` API once shipped in
// https://github.com/bitcoindevkit/rust-electrum-client/pull/213.
//
// This could still accept an Electrum server running against Bitcoin Core v26
// through v28, which does not relay ephemeral dust.
let spawn_fut = electrum_client.runtime.spawn_blocking({
let electrum_client = Arc::clone(&electrum_client.electrum_client);
move || electrum_client.transaction_broadcast_package(&super::dummy_package())
Expand All @@ -336,11 +332,19 @@ impl ElectrumChainSource {
);

match timeout_fut.await {
Ok(Ok(Ok(_))) => Ok(()),
Ok(Ok(Err(
e @ (electrum_client::Error::Protocol(_)
| electrum_client::Error::AllAttemptsErrored(_)),
))) => {
Ok(Ok(Ok(result))) => {
if dummy_submit_package_result_matches_v29_or_later(&result) {
Ok(())
} else {
log_error!(
self.logger,
"Electrum server does not support submitpackage: {:?}",
result
);
Err(Error::ChainSourceNotSupported)
}
},
Ok(Ok(Err(e))) if electrum_submitpackage_error_implies_unsupported(&e) => {
log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e);
Err(Error::ChainSourceNotSupported)
},
Expand Down Expand Up @@ -377,6 +381,22 @@ impl ElectrumChainSource {
}
}

fn electrum_submitpackage_error_implies_unsupported(e: &electrum_client::Error) -> bool {
matches!(e, electrum_client::Error::Protocol(_) | electrum_client::Error::AllAttemptsErrored(_))
}

fn dummy_submit_package_result_matches_v29_or_later(result: &BroadcastPackageRes) -> bool {
if result.success {
return false;
}

super::dummy_package_txids().iter().all(|expected_txid| {
result.errors.iter().any(|error| {
&error.txid == expected_txid && error.error == super::DUMMY_PACKAGE_EXPECTED_ERROR
})
})
}

impl Filter for ElectrumChainSource {
fn register_tx(&self, txid: &Txid, script_pubkey: &Script) {
self.electrum_runtime_status.write().expect("lock").register_tx(txid, script_pubkey)
Expand Down
43 changes: 30 additions & 13 deletions src/chain/esplora.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use bdk_esplora::EsploraAsyncExt;
use bitcoin::transaction::Version;
use bitcoin::{FeeRate, Network, Script, Txid};
use esplora_client::AsyncClient as EsploraAsyncClient;
use esplora_client::{AsyncClient as EsploraAsyncClient, SubmitPackageResult};
use lightning::chain::{Confirm, Filter, WatchedOutput};
use lightning::util::ser::Writeable;
use lightning_transaction_sync::EsploraSyncClient;
Expand Down Expand Up @@ -86,16 +86,13 @@ impl EsploraChainSource {
}

pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> {
// This could still accept an Esplora server running against Bitcoin Core v26
// through v28, which does not relay ephemeral dust.
self.esplora_client.submit_package(&super::dummy_package(), None, None).await.map_err(
|e| {
if let esplora_client::Error::HttpResponse { status: 404, message } = e {
log_error!(
self.logger,
"Esplora server does not support submitpackage: {}",
message
);
let result = self
.esplora_client
.submit_package(&super::dummy_package(), None, None)
.await
.map_err(|e| {
if esplora_submitpackage_error_implies_unsupported(&e) {
log_error!(self.logger, "Esplora server does not support submitpackage: {}", e);
Error::ChainSourceNotSupported
} else {
log_error!(
Expand All @@ -105,8 +102,11 @@ impl EsploraChainSource {
);
Error::ConnectionFailed
}
},
)?;
})?;
if !dummy_submit_package_result_matches_v29_or_later(&result) {
log_error!(self.logger, "Esplora server does not support submitpackage: {:?}", result);
return Err(Error::ChainSourceNotSupported);
}
Ok(())
}

Expand Down Expand Up @@ -521,6 +521,23 @@ impl EsploraChainSource {
}
}

fn esplora_submitpackage_error_implies_unsupported(e: &esplora_client::Error) -> bool {
matches!(e, esplora_client::Error::HttpResponse { status: 400 | 404, .. })

@tankyleo tankyleo Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We map 400 here to ChainSourceNotSupported because Bitcoin Core v26 returns a RPC error when submitting the dummy package, which maps to error code 400.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grrr, this is all very brittle. I can't wait to drop all of this logic again once we can just assume submitpackage is available if we have any chain source at all.

}

fn dummy_submit_package_result_matches_v29_or_later(result: &SubmitPackageResult) -> bool {
if result.package_msg != "transaction failed" {
return false;
}

super::dummy_package_txids().iter().all(|expected_txid| {
result.tx_results.values().any(|tx_result| {
&tx_result.txid == expected_txid
&& tx_result.error.as_deref() == Some(super::DUMMY_PACKAGE_EXPECTED_ERROR)
})
})
}

impl Filter for EsploraChainSource {
fn register_tx(&self, txid: &Txid, script_pubkey: &Script) {
self.tx_sync.register_tx(txid, script_pubkey);
Expand Down
35 changes: 26 additions & 9 deletions src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,20 @@ use crate::{Error, PersistedNodeMetrics};

/// 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 DUMMY_PACKAGE_EXPECTED_ERROR: &str = "bad-txns-inputs-missingorspent";
const PARENT_TXID: &str = "11105ba7c94f2fdc1b870a9fbdb136ca006bcf598c372548a1a3051001f3238f";
const PARENT_HEX: &str =
"0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000fd\
"0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000ff\
ffffff0201000000000000000451024e73876100000000000022512042731375894dad3b25092cd0f713dc5bee4\
a71e30a95e1db3d880906d7eba1fa01409327942924218e4eb1635a7cce6706fcb37b8bbb61a2f0b86357356681\
4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f115a97b0e00";
const CHILD_TXID: &str = "d011b3ff78cdfb8b93822639ea87771847936b04bb83afc8763a7c02a386ae26";
4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f11500000000";
const CHILD_TXID: &str = "6a051464dbf0534a060a61355a6a971559de004795328f8dfd19f3197f9bb4b0";
const CHILD_HEX: &str =
"0300000000010296f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0000000000ff\
ffffff96f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0100000000fdffffff015\
"030000000001028f23f3011005a3a14825378c59cf6b00ca36b1bd9f0a871bdc2f4fc9a75b10110000000000ff\
ffffff8f23f3011005a3a14825378c59cf6b00ca36b1bd9f0a871bdc2f4fc9a75b10110100000000ffffffff015\
660000000000000225120ac18cd599a1be003595854e2eeec18dbe1c92d04b0ba05812d04445e3fcf16bc000140\
1462a35808d77a164f0a23a84c4721d1545befd09ad19945bb8aa0ea5576953a9699038725f944b1bc429942ef4\
7e6504a554babf022cb15db53be2d8c1dbfe5a97b0e00";
7e6504a554babf022cb15db53be2d8c1dbfe500000000";

fn dummy_package() -> [bitcoin::Transaction; 2] {
use bitcoin::consensus::Decodable;
Expand All @@ -55,11 +56,27 @@ fn dummy_package() -> [bitcoin::Transaction; 2] {
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);
let [parent_txid, child_txid] = dummy_package_txids();
assert_eq!(parent.compute_txid(), parent_txid);
assert_eq!(child.compute_txid(), child_txid);
assert_eq!(parent.lock_time, bitcoin::absolute::LockTime::ZERO);
assert_eq!(child.lock_time, bitcoin::absolute::LockTime::ZERO);
assert!(parent
.input
.iter()
.chain(child.input.iter())
.all(|input| input.sequence == bitcoin::Sequence::MAX));
assert!(child.input.iter().all(|input| input.previous_output.txid == parent.compute_txid()));
[parent, child]
}

fn dummy_package_txids() -> [Txid; 2] {
[
PARENT_TXID.parse().expect("read from a constant"),
CHILD_TXID.parse().expect("read from a constant"),
]
}

pub(crate) enum WalletSyncStatus {
Completed,
InProgress { subscribers: tokio::sync::broadcast::Sender<Result<(), Error>> },
Expand Down
81 changes: 80 additions & 1 deletion src/ffi/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ pub use lightning_liquidity::lsps0::ser::LSPSDateTime;
pub use lightning_liquidity::lsps1::msgs::{
LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentState,
};
use lightning_types::features::{InitFeatures as LdkInitFeatures, NodeFeatures as LdkNodeFeatures};
use lightning_types::features::{
ChannelTypeFeatures as LdkChannelTypeFeatures, InitFeatures as LdkInitFeatures,
NodeFeatures as LdkNodeFeatures,
};
pub use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret};
pub use lightning_types::string::UntrustedString;
use vss_client::headers::{
Expand Down Expand Up @@ -1817,6 +1820,82 @@ impl From<LdkNodeFeatures> for NodeFeatures {
}
}

#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)]
#[uniffi::export(Debug, Eq)]
pub struct ChannelTypeFeatures {
pub(crate) inner: LdkChannelTypeFeatures,
}

#[uniffi::export]
impl ChannelTypeFeatures {
/// Constructs channel type features from big-endian BOLT 9 encoded bytes.
#[uniffi::constructor]
pub fn from_bytes(bytes: &[u8]) -> Self {
Self { inner: LdkChannelTypeFeatures::from_be_bytes(bytes.to_vec()) }
}

/// Returns the BOLT 9 big-endian encoded representation of these features.
pub fn to_bytes(&self) -> Vec<u8> {
self.inner.encode()
}

/// Whether this channel type advertises support for `option_static_remotekey`.
pub fn supports_static_remote_key(&self) -> bool {
self.inner.supports_static_remote_key()
}

/// Whether this channel type requires `option_static_remotekey`.
pub fn requires_static_remote_key(&self) -> bool {
self.inner.requires_static_remote_key()
}

/// Whether this channel type advertises support for `option_anchors_zero_fee_htlc_tx`.
pub fn supports_anchors_zero_fee_htlc_tx(&self) -> bool {
self.inner.supports_anchors_zero_fee_htlc_tx()
}

/// Whether this channel type requires `option_anchors_zero_fee_htlc_tx`.
pub fn requires_anchors_zero_fee_htlc_tx(&self) -> bool {
self.inner.requires_anchors_zero_fee_htlc_tx()
}

/// Whether this channel type advertises support for `option_anchors_nonzero_fee_htlc_tx`.
pub fn supports_anchors_nonzero_fee_htlc_tx(&self) -> bool {
self.inner.supports_anchors_nonzero_fee_htlc_tx()
}

/// Whether this channel type requires `option_anchors_nonzero_fee_htlc_tx`.
pub fn requires_anchors_nonzero_fee_htlc_tx(&self) -> bool {
self.inner.requires_anchors_nonzero_fee_htlc_tx()
}

/// Whether this channel type advertises support for `option_taproot`.
pub fn supports_taproot(&self) -> bool {
self.inner.supports_taproot()
}

/// Whether this channel type requires `option_taproot`.
pub fn requires_taproot(&self) -> bool {
self.inner.requires_taproot()
}

/// Whether this channel type advertises support for `option_zero_fee_commitments`.
pub fn supports_anchor_zero_fee_commitments(&self) -> bool {
self.inner.supports_anchor_zero_fee_commitments()
}

/// Whether this channel type requires `option_zero_fee_commitments`.
pub fn requires_anchor_zero_fee_commitments(&self) -> bool {
self.inner.requires_anchor_zero_fee_commitments()
}
}

impl From<LdkChannelTypeFeatures> for ChannelTypeFeatures {
fn from(features: LdkChannelTypeFeatures) -> Self {
Self { inner: features }
}
}

#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)]
#[uniffi::export(Debug, Eq)]
pub struct InitFeatures {
Expand Down
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ impl Node {
return Err(Error::AlreadyRunning);
}

match self.start_inner(&mut is_running_lock) {
Ok(()) => Ok(()),
Err(e) => {
self.chain_source.stop();
Err(e)
},
}
}

fn start_inner(&self, is_running_lock: &mut bool) -> Result<(), Error> {
log_info!(
self.logger,
"Starting up LDK Node with node ID {} on network: {}",
Expand Down
2 changes: 1 addition & 1 deletion src/tx_broadcaster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::logger::{log_error, LdkLogger};
use crate::types::Wallet;
use crate::Error;

const BCAST_PACKAGE_QUEUE_SIZE: usize = 50;
const BCAST_PACKAGE_QUEUE_SIZE: usize = 256;

@tankyleo tankyleo Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went back and forth with codex on this one, for now I lean against drawing transactions at random order: we do not do this now, but in the future we might want to make sure we broadcast parents before children no ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but in the future we might want to make sure we broadcast parents before children no ?

Well, in short, I'm not sure we could even begin to guarantee this? Broadcast is inherently fallible, we never know when the network connection could drop, when the backend won't accept anything into the mempool, and when it will just decide to drop any transaction again. So without mempool introspection I'd always lean on treating broadcast as an entirely opaque operation: we submit and retry, and only stop once we see what we expect confirmed in a block.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sounds good added the random draw below


/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions`
/// call, along with each transaction's type. Queued until the background task classifies and
Expand Down
Loading
Loading