From 7e6c231c6c7dd5688aa61281fc1be4b1b552bb8e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 17 Sep 2026 09:54:27 +0200 Subject: [PATCH] Expose pending HTLCs and channel dust exposure Make pending payment state and dust exposure available to callers so they can inspect stalled payments and assess force-close risk. Also expose the maximum splice-out amount to help plan withdrawals. Expose the same information through Rust and the language bindings. Rust and UniFFI checks, unit tests, the new integration test in both configurations, doctests, and binding generation pass. The full integration run had two failures in existing tests. Both passed in isolation, but channel_full_cycle_force_close_trusted_no_reserve failed again with Bitcoin Core RPC at tests/common/mod.rs:632. Its clean-main run passed, so the cause remains unresolved. UniFFI rustdoc also fails on three broken links reproduced on main. Fixes #1102 Co-Authored-By: HAL 9000 --- src/ffi/types.rs | 138 +++++++++++++++++++++++++++++++- src/lib.rs | 5 +- src/types.rs | 22 +++++ tests/integration_tests_rust.rs | 111 +++++++++++++++++++++++++ 4 files changed, 274 insertions(+), 2 deletions(-) diff --git a/src/ffi/types.rs b/src/ffi/types.rs index a153a77ef5..2e8bb68e49 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -24,7 +24,10 @@ use bitcoin::secp256k1::PublicKey; pub use bitcoin::{Address, BlockHash, Network, OutPoint, ScriptBuf, Txid}; pub use lightning::chain::channelmonitor::BalanceSource; pub use lightning::events::{ClosureReason, PaymentFailureReason}; -use lightning::ln::channel_state::{ChannelShutdownState, CounterpartyForwardingInfo}; +use lightning::ln::channel_state::{ + ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, + OutboundHTLCDetails, OutboundHTLCStateDetails, +}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; pub use lightning::ln::types::ChannelId; @@ -1700,6 +1703,139 @@ pub enum ChannelShutdownState { ShutdownComplete, } +/// Exposes the state of pending inbound HTLCs. +/// +/// This can be used to inspect what next message an HTLC is waiting for to advance its state. +#[uniffi::remote(Enum)] +#[derive(Clone, Debug, PartialEq)] +pub enum InboundHTLCStateDetails { + /// We have added this HTLC in our commitment transaction by receiving commitment_signed and + /// returning revoke_and_ack. We are awaiting the appropriate revoke_and_ack's from the remote + /// before this HTLC is included on the remote commitment transaction. + AwaitingRemoteRevokeToAdd, + /// This HTLC has been included in the commitment_signed and revoke_and_ack messages on both sides + /// and is included in both commitment transactions. + /// + /// This HTLC is now safe to either forward or be claimed as a payment by us. The HTLC will + /// remain in this state until the forwarded upstream HTLC has been resolved and we resolve this + /// HTLC correspondingly, or until we claim it as a payment. If it is part of a multipart + /// payment, it will only be claimed together with other required parts. + Committed, + /// We have received the preimage for this HTLC and it is being removed by fulfilling it with + /// update_fulfill_htlc. This HTLC is still on both commitment transactions, but we are awaiting + /// the appropriate revoke_and_ack's from the remote before this HTLC is removed from the remote + /// commitment transaction after update_fulfill_htlc. + AwaitingRemoteRevokeToRemoveFulfill, + /// The HTLC is being removed by failing it with update_fail_htlc or update_fail_malformed_htlc. + /// This HTLC is still on both commitment transactions, but we are awaiting the appropriate + /// revoke_and_ack's from the remote before this HTLC is removed from the remote commitment + /// transaction. + AwaitingRemoteRevokeToRemoveFail, +} + +/// Exposes details around pending inbound HTLCs. +#[uniffi::remote(Record)] +pub struct InboundHTLCDetails { + /// The HTLC ID. + /// The IDs are incremented by 1 starting from 0 for each offered HTLC. + /// They are unique per channel and inbound/outbound direction, unless an HTLC was only announced + /// and not part of any commitment transaction. + pub htlc_id: u64, + /// The amount in msat. + pub amount_msat: u64, + /// The block height at which this HTLC expires. + pub cltv_expiry: u32, + /// The payment hash. + pub payment_hash: PaymentHash, + /// The state of the HTLC in the state machine. + /// + /// Determines on which commitment transactions the HTLC is included and what message the HTLC is + /// waiting for to advance to the next state. + /// + /// LDK will always fill this field in, but when downgrading to prior versions of LDK, new + /// states may result in `None` here. + pub state: Option, + /// Whether the HTLC has an output below the local dust limit. If so, the output will be trimmed + /// from the local commitment transaction and added to the commitment transaction fee. + /// For non-anchor channels, this takes into account the cost of the second-stage HTLC + /// transactions as well. + /// + /// When the local commitment transaction is broadcasted as part of a unilateral closure, + /// the value of this HTLC will therefore not be claimable but instead burned as a transaction + /// fee. + /// + /// Note that dust limits are specific to each party. An HTLC can be dust for the local + /// commitment transaction but not for the counterparty's commitment transaction and vice versa. + pub is_dust: bool, +} + +/// Exposes the state of pending outbound HTLCs. +/// +/// This can be used to inspect what next message an HTLC is waiting for to advance its state. +#[uniffi::remote(Enum)] +#[derive(Clone, Debug, PartialEq)] +pub enum OutboundHTLCStateDetails { + /// We are awaiting the appropriate revoke_and_ack's from the remote before the HTLC is added + /// on the remote's commitment transaction after update_add_htlc. + AwaitingRemoteRevokeToAdd, + /// The HTLC has been added to the remote's commitment transaction by sending commitment_signed + /// and receiving revoke_and_ack in return. + /// + /// The HTLC will remain in this state until the remote node resolves the HTLC, or until we + /// unilaterally close the channel due to a timeout with an uncooperative remote node. + Committed, + /// The HTLC has been fulfilled successfully by the remote with a preimage in update_fulfill_htlc, + /// and we removed the HTLC from our commitment transaction by receiving commitment_signed and + /// returning revoke_and_ack. We are awaiting the appropriate revoke_and_ack's from the remote + /// for the removal from its commitment transaction. + AwaitingRemoteRevokeToRemoveSuccess, + /// The HTLC has been failed by the remote with update_fail_htlc or update_fail_malformed_htlc, + /// and we removed the HTLC from our commitment transaction by receiving commitment_signed and + /// returning revoke_and_ack. We are awaiting the appropriate revoke_and_ack's from the remote + /// for the removal from its commitment transaction. + AwaitingRemoteRevokeToRemoveFailure, +} + +/// Exposes details around pending outbound HTLCs. +#[uniffi::remote(Record)] +pub struct OutboundHTLCDetails { + /// The HTLC ID. + /// The IDs are incremented by 1 starting from 0 for each offered HTLC. + /// They are unique per channel and inbound/outbound direction, unless an HTLC was only announced + /// and not part of any commitment transaction. + /// + /// Not present when we are awaiting a remote revocation and the HTLC is not added yet. + pub htlc_id: Option, + /// The amount in msat. + pub amount_msat: u64, + /// The block height at which this HTLC expires. + pub cltv_expiry: u32, + /// The payment hash. + pub payment_hash: PaymentHash, + /// The state of the HTLC in the state machine. + /// + /// Determines on which commitment transactions the HTLC is included and what message the HTLC is + /// waiting for to advance to the next state. + /// + /// LDK will always fill this field in, but when downgrading to prior versions of LDK, new + /// states may result in `None` here. + pub state: Option, + /// The extra fee being skimmed off the top of this HTLC. + pub skimmed_fee_msat: Option, + /// Whether the HTLC has an output below the local dust limit. If so, the output will be trimmed + /// from the local commitment transaction and added to the commitment transaction fee. + /// For non-anchor channels, this takes into account the cost of the second-stage HTLC + /// transactions as well. + /// + /// When the local commitment transaction is broadcasted as part of a unilateral closure, + /// the value of this HTLC will therefore not be claimable but instead burned as a transaction + /// fee. + /// + /// Note that dust limits are specific to each party. An HTLC can be dust for the local + /// commitment transaction but not for the counterparty's commitment transaction and vice versa. + pub is_dust: bool, +} + /// The reason the channel was closed. See individual variants for more details. #[uniffi::remote(Enum)] #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/src/lib.rs b/src/lib.rs index 6d13141c6a..526e3ec746 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,7 +155,10 @@ use lightning::chain::BlockLocator; use lightning::impl_writeable_tlv_based; use lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use lightning::ln::channel_state::ChannelDetails as LdkChannelDetails; -pub use lightning::ln::channel_state::ChannelShutdownState; +pub use lightning::ln::channel_state::{ + ChannelShutdownState, InboundHTLCDetails, InboundHTLCStateDetails, OutboundHTLCDetails, + OutboundHTLCStateDetails, +}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::{BaseMessageHandler, SocketAddress}; use lightning::ln::peer_handler::CustomMessageHandler; diff --git a/src/types.rs b/src/types.rs index fd86d1bcd8..25c7870871 100644 --- a/src/types.rs +++ b/src/types.rs @@ -16,6 +16,7 @@ use lightning::chain::chainmonitor; use lightning::impl_writeable_tlv_based; use lightning::ln::channel_state::{ ChannelDetails as LdkChannelDetails, ChannelShutdownState, CounterpartyForwardingInfo, + InboundHTLCDetails, OutboundHTLCDetails, }; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::IgnoringMessageHandler; @@ -586,6 +587,8 @@ pub struct ChannelDetails { /// an upper-bound. This is intended for use when routing, allowing us to ensure we pick a /// route which is valid. pub next_outbound_htlc_minimum_msat: u64, + /// The maximum value, in satoshis, of the next splice out from our channel balance. + pub next_splice_out_maximum_sat: u64, /// The number of blocks (after our commitment transaction confirms) that we will need to wait /// until we can claim our funds after we force-close the channel. During this time our /// counterparty is allowed to punish us if we broadcasted a stale state. If our counterparty @@ -616,6 +619,21 @@ pub struct ChannelDetails { /// Will be `None` until channel negotiation has completed and the channel type has been /// determined. pub channel_type: Option, + /// Pending inbound HTLCs. + pub pending_inbound_htlcs: Vec, + /// Pending outbound HTLCs. + pub pending_outbound_htlcs: Vec, + /// The current total dust exposure on this channel, in millisatoshis. + /// + /// This is the maximum of the dust exposure on the holder and counterparty commitment + /// transactions. It includes pending HTLCs below the dust threshold and the portion of + /// commitment transaction fees that contributes to dust exposure. + /// + /// This is compared against [`ChannelConfig::max_dust_htlc_exposure`] when determining whether + /// new HTLCs can be accepted or offered on this channel. + /// + /// Will be `None` for objects serialized with LDK versions prior to 0.3. + pub current_dust_exposure_msat: Option, } impl ChannelDetails { @@ -669,6 +687,7 @@ impl ChannelDetails { cltv_expiry_delta: value.config.map(|c| c.cltv_expiry_delta), next_outbound_htlc_limit_msat: value.next_outbound_htlc_limit_msat, next_outbound_htlc_minimum_msat: value.next_outbound_htlc_minimum_msat, + next_splice_out_maximum_sat: value.next_splice_out_maximum_sat, force_close_spend_delay: value.force_close_spend_delay, inbound_htlc_minimum_msat: value .inbound_htlc_minimum_msat @@ -681,6 +700,9 @@ impl ChannelDetails { channel_shutdown_state: value.channel_shutdown_state, reserve_type, channel_type: value.channel_type.map(maybe_wrap), + pending_inbound_htlcs: value.pending_inbound_htlcs, + pending_outbound_htlcs: value.pending_outbound_htlcs, + current_dust_exposure_msat: value.current_dust_exposure_msat, } } } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 83e80c1048..e9fa36ab53 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -460,6 +460,117 @@ async fn channel_full_cycle() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn channel_details_pending_htlcs() { + use ldk_node::{InboundHTLCStateDetails, OutboundHTLCStateDetails}; + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![ + node_a.onchain_payment().new_address().unwrap(), + node_b.onchain_payment().new_address().unwrap(), + ], + Amount::from_sat(1_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + open_channel(&node_a, &node_b, 500_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + for node in [&node_a, &node_b] { + let channel = node.list_channels().remove(0); + assert!(channel.pending_inbound_htlcs.is_empty()); + assert!(channel.pending_outbound_htlcs.is_empty()); + assert_eq!(channel.current_dust_exposure_msat, Some(0)); + } + let initial_splice_out_maximum_sat = node_a.list_channels()[0].next_splice_out_maximum_sat; + assert!(initial_splice_out_maximum_sat > 0); + assert!(initial_splice_out_maximum_sat < 500_000); + + // Keep both a dust and a non-dust HTLC pending until we explicitly resolve them. + let amounts_msat = [1_000, 10_000_000]; + let description = + Bolt11InvoiceDescription::Direct(Description::new("pending HTLCs".to_owned()).unwrap()); + let mut payments = Vec::new(); + for (index, amount_msat) in amounts_msat.into_iter().enumerate() { + let preimage = PaymentPreimage([index as u8 + 1; 32]); + let payment_hash = PaymentHash::from(preimage); + let invoice = node_b + .bolt11_payment() + .receive_for_hash(amount_msat, &description.clone().into(), 3600, payment_hash) + .unwrap(); + let outbound_id = node_a.bolt11_payment().send(&invoice, None).unwrap(); + let (inbound_id, _) = expect_payment_claimable_event!(node_b, payment_hash, amount_msat); + payments.push((outbound_id, inbound_id, payment_hash, preimage)); + } + + let outbound_channel = node_a.list_channels().remove(0); + let inbound_channel = node_b.list_channels().remove(0); + assert_eq!(outbound_channel.pending_outbound_htlcs.len(), 2); + assert!(outbound_channel.pending_inbound_htlcs.is_empty()); + assert_eq!(inbound_channel.pending_inbound_htlcs.len(), 2); + assert!(inbound_channel.pending_outbound_htlcs.is_empty()); + assert_eq!(outbound_channel.current_dust_exposure_msat, Some(amounts_msat[0])); + assert_eq!(inbound_channel.current_dust_exposure_msat, Some(amounts_msat[0])); + assert!(outbound_channel.next_splice_out_maximum_sat < initial_splice_out_maximum_sat); + for (index, (_, _, payment_hash, _)) in payments.iter().enumerate() { + let outbound = outbound_channel + .pending_outbound_htlcs + .iter() + .find(|htlc| htlc.payment_hash == *payment_hash) + .unwrap(); + let inbound = inbound_channel + .pending_inbound_htlcs + .iter() + .find(|htlc| htlc.payment_hash == *payment_hash) + .unwrap(); + assert_eq!(outbound.htlc_id, Some(inbound.htlc_id)); + assert_eq!(outbound.amount_msat, amounts_msat[index]); + assert_eq!(inbound.amount_msat, amounts_msat[index]); + assert_eq!(outbound.cltv_expiry, inbound.cltv_expiry); + assert!(inbound.cltv_expiry > node_b.status().current_best_block.height); + assert_eq!(outbound.state, Some(OutboundHTLCStateDetails::Committed)); + assert_eq!(inbound.state, Some(InboundHTLCStateDetails::Committed)); + assert_eq!(outbound.is_dust, index == 0); + assert_eq!(inbound.is_dust, index == 0); + assert_eq!(outbound.skimmed_fee_msat, None); + } + + let (outbound_id, inbound_id, _, preimage) = payments[0]; + node_b.bolt11_payment().claim_for_id(inbound_id, amounts_msat[0], preimage).unwrap(); + expect_payment_received_event!(node_b, amounts_msat[0]); + expect_payment_successful_event!(node_a, outbound_id, None); + node_b.bolt11_payment().fail_for_id(payments[1].1).unwrap(); + expect_event!(node_a, PaymentFailed); + + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + loop { + let channels = [node_a.list_channels().remove(0), node_b.list_channels().remove(0)]; + if channels.iter().all(|channel| { + channel.pending_inbound_htlcs.is_empty() + && channel.pending_outbound_htlcs.is_empty() + }) { + for channel in channels { + assert_eq!(channel.current_dust_exposure_msat, Some(0)); + } + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("resolved HTLCs should disappear from channel details"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle_force_close() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();