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 @@ -46,4 +46,4 @@ jobs:
echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV"
- name: Test with 0FC enabled
run: |
RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable --cfg zero_fee_commitment_tests" cargo test -- --test-threads=1
RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable --cfg zero_fee_commitment_tests" cargo test -- --test-threads=1 --no-capture
15 changes: 14 additions & 1 deletion .github/workflows/eclair-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ concurrency:

jobs:
check-eclair:
name: check-eclair (${{ matrix.name }})
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
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 +47,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 +66,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
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
10 changes: 10 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,16 @@ impl Node {
return Err(Error::AlreadyRunning);
}

match self.start_inner(&mut is_running_lock) {

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:

  • [P1] Fully unwind late startup failures — /home/tnull/worktrees/ldk-node/pr-992-latest-20260724/src/lib.rs:295

    A listener resolution/bind failure can occur after wallet sync and other background tasks have already spawned. The new error handler only stops the chain source. Since is_running remains false, callers cannot invoke stop(), and retrying start() leaves duplicate tasks running. Fallible
    listener setup should occur before spawning tasks, or the error path must fully unwind them.

Seems that might be worth an additional PR though. Let me know if you prefer I pick that up.

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.

Let me know what you think of the commit below, we move the fallible operations before starting the tasks.

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.

I don't think we'd want to do this, as we deliberately allow inbound connections as one of the last steps after we're positive we're ready-to-go.

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.

Help me understand thank you, once we've passed all the can-fail calls, aren't we positive we are ready to go ?

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.

Ah, I think I was misunderstanding what you're doing. Still, just moving code around so we don't start tasks before possibly emitting an error seems very brittle. We might easily add another error'ing case sometime soon, and then have the same issue again. We probably need to fix it 'properly' by signalling stop to the tasks etc., but doing this is probably out-of-scope for this PR as mentioned above.

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 I kept the commit below, and we can add the proper signalling in the next PR.

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.

No, I'd prefer to drop the extra diff there, as splitting the listener logic in two just makes it harder to follow going forward. We'll want to keep that code block together, but need to address the 'wind down spawned tasks on error otherwise'.

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.

Ok I dropped the diff, and filed an issue.

Ok(()) => Ok(()),
Err(e) => {
self.chain_source.stop();

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] Roll back background tasks after startup failure. /home/tnull/worktrees/ldk-node/pr-992-latest-20260721/src/lib.rs:294 now stops only the chain source when start_inner fails. However, wallet sync, RGS, and pathfinding tasks are spawned before listener resolution/binding can fail at
    lines 427–473. The node remains “not running,” so stop() cannot clean them up, while another start() creates duplicate loops. Move fallible listener setup before task spawning or perform a complete task rollback.

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
10 changes: 10 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ use lightning::util::sweep::OutputSweeper;
use lightning_block_sync::gossip::GossipVerifier;
use lightning_liquidity::utils::time::DefaultTimeProvider;
use lightning_net_tokio::SocketDescriptor;
#[cfg(not(feature = "uniffi"))]
use lightning_types::features::ChannelTypeFeatures;

use crate::chain::bitcoind::UtxoSourceClient;
use crate::chain::ChainSource;
Expand All @@ -51,6 +53,8 @@ use crate::message_handler::NodeCustomMessageHandler;
use crate::payment::{PaymentDetails, PendingPaymentDetails};
use crate::runtime::RuntimeSpawner;

#[cfg(feature = "uniffi")]
type ChannelTypeFeatures = Arc<crate::ffi::ChannelTypeFeatures>;
#[cfg(not(feature = "uniffi"))]
type InitFeatures = lightning::types::features::InitFeatures;
#[cfg(feature = "uniffi")]
Expand Down Expand Up @@ -642,6 +646,11 @@ pub struct ChannelDetails {
///
/// See [`ReserveType`] for details on how reserves differ between anchor and legacy channels.
pub reserve_type: Option<ReserveType>,
/// The negotiated channel type features.
///
/// Will be `None` until channel negotiation has completed and the channel type has been
/// determined.
pub channel_type: Option<ChannelTypeFeatures>,
}

impl ChannelDetails {
Expand Down Expand Up @@ -706,6 +715,7 @@ impl ChannelDetails {
.expect("value is set for objects serialized with LDK v0.0.109+"),
channel_shutdown_state: value.channel_shutdown_state,
reserve_type,
channel_type: value.channel_type.map(maybe_wrap),
}
}
}
Expand Down
13 changes: 8 additions & 5 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1578,12 +1578,15 @@ pub(crate) async fn do_channel_full_cycle<E: ElectrumApi>(
);

if disable_node_b_reserve {
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;
let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0;
let node_a_channel = node_a.list_channels().into_iter().next().unwrap();
let node_a_outbound_capacity_msat = node_a_channel.outbound_capacity_msat;
let node_a_reserve_msat = node_a_channel.unspendable_punishment_reserve.unwrap() * 1000;
let zero_fee_commitments = node_a_channel
.channel_type
.as_ref()
.map_or(false, |c| c.requires_anchor_zero_fee_commitments());
let node_a_anchors_msat = if zero_fee_commitments { 0 } else { 2 * 330 * 1000 };
let funding_amount_msat = node_a.list_channels()[0].channel_value_sats * 1000;
let funding_amount_msat = node_a_channel.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
let node_b_capacity_msat = funding_amount_msat
Expand Down
17 changes: 17 additions & 0 deletions tests/common/scenarios/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use std::time::Duration;

use electrsd::corepc_node::Client as BitcoindClient;
use electrsd::electrum_client::ElectrumApi;
#[cfg(all(eclair_test, zero_fee_commitment_tests))]
use ldk_node::ReserveType;
use ldk_node::{Event, Node};

use super::super::external_node::ExternalNode;
Expand Down Expand Up @@ -41,6 +43,21 @@ pub(crate) async fn open_channel_to_external<E: ElectrumApi>(
.map(|ch| ch.channel_id.clone())
.unwrap_or_else(|| panic!("Could not find channel on external node {}", peer.name()));

#[cfg(all(eclair_test, zero_fee_commitment_tests))]
{
let channel = node
.list_channels()
.into_iter()
.find(|channel| channel.user_channel_id == user_channel_id)
.expect("opened channel should be listed");
let channel_type = channel.channel_type.as_ref().expect("channel type should be set");
assert_eq!(channel.counterparty.node_id, ext_node_id);
assert!(channel.counterparty.features.supports_anchor_zero_fee_commitments());
assert!(channel_type.requires_anchor_zero_fee_commitments());
assert_eq!(channel.feerate_sat_per_1000_weight, 0);
assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive));
}

(user_channel_id, ext_channel_id)
}

Expand Down
6 changes: 3 additions & 3 deletions tests/common/scenarios/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,15 @@ pub(crate) async fn wait_for_htlcs_settled(
panic!("HTLCs did not settle on {} channel {} within 15s", peer.name(), ext_channel_id);
}

/// Build a fresh LDK node configured for interop tests. Uses electrum at the
/// Build a fresh LDK node configured for interop tests. Uses esplora at the
/// docker-compose default port and bumps sync timeouts for combo stress.
pub(crate) fn setup_ldk_node() -> Node {
let config = crate::common::random_config();
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
Expand Down
1 change: 1 addition & 0 deletions tests/docker/docker-compose-eclair.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,5 @@ services:
-Declair.bitcoind.zmqtx=tcp://127.0.0.1:28333
-Declair.features.keysend=optional
-Declair.on-chain-fees.confirmation-priority.funding=slow
${ECLAIR_EXTRA_JAVA_OPTS:-}
-Declair.printToConsole
4 changes: 3 additions & 1 deletion tests/integration_tests_rust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1706,7 +1706,9 @@ 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 zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0;
let channel = node_a.list_channels().into_iter().next().unwrap();
let zero_fee_commitments =
channel.channel_type.as_ref().map_or(false, |c| c.requires_anchor_zero_fee_commitments());
let closing_transaction_fee_sat = if zero_fee_commitments { 0 } else { 614 };
let anchor_output_sat = if zero_fee_commitments { 0 } else { 330 };

Expand Down
Loading