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
2 changes: 1 addition & 1 deletion .github/workflows/0fc-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ concurrency:

jobs:
build-and-test:
timeout-minutes: 60
timeout-minutes: 75
runs-on: self-hosted
steps:
- name: Checkout source code
Expand Down
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";

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.

Hmm, matching on the exact string seems not very robust. Seems some minor change in how Bitcoin Core handles errors could lead to LDK Node not starting anymore.

As mentioned on #660 (comment), maybe we just need to accept that we currently don't have a good way of checking all backends support v29+? Should we drop this commit?

@tankyleo tankyleo Jul 24, 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 could totally see us dropping this commit. Here's my argument against dropping it:

  • We'd only abort startup in case enable_zero_fee_commitments is true, and we default to false at the moment. So given this default, I think we can be more aggressive in how we check for 0FC support once the flag is turned on. If someone turns this flag on and fails to start, they can easily turn this flag back off.

  • With codex, I've tested this validation across 26, 27, 28, 29, 30, and 31, and across mempool, blockstream, romanz, Fulcrum, and ElectrumX. With codex I think we can easily test this logic against upcoming releases as they come out, since Core only ships two releases a year, and we can adjust this as the reality on the ground changes.

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
122 changes: 121 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,102 @@ 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 {

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.

Codex:

  • [P2] Expose all channel-type feature flags to bindings — /home/tnull/worktrees/ldk-node/pr-992-latest-20260724/src/ffi/types.rs:1830

    ChannelTypeFeatures omits typed accessors for option_scid_alias and option_zeroconf, although both are valid ChannelTypeContext features supported by the underlying type. Rust callers retain those methods, but UniFFI users must manually decode to_bytes().

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.

Done below.

/// 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_scid_alias`.
pub fn supports_scid_privacy(&self) -> bool {
self.inner.supports_scid_privacy()
}

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

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

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

/// 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 Expand Up @@ -2191,6 +2290,27 @@ mod tests {
(ldk_invoice, wrapped_invoice)
}

#[test]
fn test_channel_type_feature_accessors() {
let mut optional = LdkChannelTypeFeatures::empty();
optional.set_scid_privacy_optional();
optional.set_zero_conf_optional();
let optional = ChannelTypeFeatures::from(optional);
assert!(optional.supports_scid_privacy());
assert!(!optional.requires_scid_privacy());
assert!(optional.supports_zero_conf());
assert!(!optional.requires_zero_conf());

let mut required = LdkChannelTypeFeatures::empty();
required.set_scid_privacy_required();
required.set_zero_conf_required();
let required = ChannelTypeFeatures::from(required);
assert!(required.supports_scid_privacy());
assert!(required.requires_scid_privacy());
assert!(required.supports_zero_conf());
assert!(required.requires_zero_conf());
}

#[test]
fn test_invoice_description_conversion() {
let hash = "09d08d4865e8af9266f6cc7c0ae23a1d6bf868207cf8f7c5979b9f6ed850dfb0".to_string();
Expand Down
Loading
Loading