Skip to content

Splicing: backport upstream #865 and #1159 to the 0.7 line - #15

Open
shubhamkmr04 wants to merge 13 commits into
ZeusLN:zeusfrom
shubhamkmr04:splice-fee-rate-0.7
Open

shubhamkmr04 wants to merge 13 commits into
ZeusLN:zeusfrom
shubhamkmr04:splice-fee-rate-0.7

Conversation

@shubhamkmr04

@shubhamkmr04 shubhamkmr04 commented Nov 4, 2026 •

Copy link
Copy Markdown
  • Cherry-picks Fix balance candidate selection during pending splices lightningdevkit/ldk-node#865 (upstream f945ade): while a splice is pending, list_balances now takes a channel's balance from the newest funding, so it matches the total.
  • Ports Allow specifying a fee rate when splicing lightningdevkit/ldk-node#1159: an optional min_fee_rate on splice_in / splice_out. 0.2's splice_channel takes a single feerate, so it's used instead of the estimate. The max-feerate/RBF parts, splice_in_with_all and the fee-bump change don't apply on 0.2.
  • Includes upstream f9c46e1 (fee-rate helper moved to ffi/mod.rs), applied by hand because onchain.rs differs here.
  • Adds splice_channel_with_fee_rate, a trimmed 0.7 version of upstream's test (11cb500): a splice-out and a splice-in at 10× the regtest estimate, each checked to pay the requested rate.
  • Not in upstream: a min_fee_rate below 253 sat/kW, or too large for LDK's u32, is rejected with InvalidFeeRate. A splice can't be fee-bumped on 0.2, so a rate that's too low would leave it stuck.
  • Swift bindings regenerated. The xcframework and checksum are left for the tag.

@shubhamkmr04 shubhamkmr04 changed the title Splicing: backport #865 and #1159 to the 0.7 line Splicing: backport upstream #865 and #1159 to the 0.7 line Nov 4, 2026

@athena-zeusln athena-zeusln Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Athena Review

Pinned commit: 4b8f638

anthropic:claude-opus-5-5: completed openai:gpt-6-sol: completed

2 validated findings. This is a nonblocking COMMENT review, not an approval. No findings does not establish correctness.

Limitations:

  • Only the Swift generated bindings are shown as updated. I cannot verify whether checked-in Kotlin or Python generated bindings, if present in this fork, were regenerated; stale ones would hit an apiChecksumMismatch at load time.
  • The new Swift checksum constants (40770, 759) cannot be verified against the UDL without running uniffi-bindgen.
  • Upstream LDK and ldk-node PRs lightningdevkit#865 and lightningdevkit#1159 were not supplied. I cannot confirm that the balance_candidates index-0 fallback to the last candidate matches LDK's semantics for the pinned LDK version.
  • The splice_in body is not fully shown. I cannot confirm that a large user-supplied fee rate is safely converted to u32 per-kw there, as it is in splice_out, or that very low or zero fee rates are rejected rather than producing a non-relayable splice.
  • The supplied patch does not include full generated binding artifacts or build results, so their synchronization cannot be assessed.

Comment thread bindings/ldk_node.udl
UserChannelId open_announced_channel_fund_max(PublicKey node_id, SocketAddress address, u64? push_to_counterparty_msat, ChannelConfig? channel_config, sequence<OutPoint>? utxos);
[Throws=NodeError]
void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats);
void splice_in([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, u64 splice_amount_sats, FeeRate? min_fee_rate);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Preserve existing binding splice signatures

The optional fee-rate value is still a required argument in the binding API. Existing binding clients calling either splice method without it no longer compile; the changed Swift signatures confirm this for Swift clients. Retain the original binding methods and add separate fee-rate variants.

Reviewers: openai:gpt-6-sol

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping the signature as is: it matches upstream lightningdevkit#1159, so ZEUS calls the same API now and after the 0.3 move. ZEUS takes bindings and libraries from the same tag, so there's no checksum mismatch, and the splice API is experimental.

Comment thread src/lib.rs
pub fn splice_in(
&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
splice_amount_sats: u64,
splice_amount_sats: u64, min_fee_rate: Option<FfiFeeRate>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

MEDIUM: Breaking public signature change for splice_in/splice_out on 0.7 line

splice\_in and splice\_out now require an additional min\_fee\_rate argument in Rust. The UniFFI methods also gain an extra parameter, which changes their checksums. On a 0.7 maintenance line this breaks downstream callers at compile time. Any foreign bindings or prebuilt native libraries not regenerated together will fail with an API checksum mismatch. The API is marked experimental, but this is still an incompatible change in a backport. Fix: keep the old method signatures and add new variants such as splice\_in\_with\_fee\_rate and splice\_out\_with\_fee\_rate, or document the break explicitly and bump the version accordingly.

Reviewers: anthropic:claude-opus-5-5, openai:gpt-6-sol

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Keeping the signature as is: it matches upstream lightningdevkit#1159, so ZEUS calls the same API now and after the 0.3 move. ZEUS takes bindings and libraries from the same tag, so there's no checksum mismatch, and the splice API is experimental.

joostjager and others added 6 commits November 11, 2026 21:56
Mirror LDK's sentinel logic for confirmed_balance_candidate_index in
ClaimableOnChannelClose: when the index is 0 (no specific alternative
funding confirmed), use the last balance candidate (most current
splice/RBF attempt) instead of the first. This aligns with LDK's
claimable_amount_satoshis() behavior and fixes a mismatch where
total_lightning_balance_sats could differ from the sum of individual
LightningBalance amounts during pending splices.

AI tools were used in preparing this commit.

(cherry picked from commit f945ade)
Replace the `maybe_map_fee_rate_opt!` macro and `FeeRate` alias in
`payment/onchain.rs` with a `maybe_map_fee_rate_opt` function and a
shared `FfiFeeRate` alias in `ffi/mod.rs`, next to the other
`uniffi`/non-`uniffi` conversion helpers, so they can be reused outside
of `OnchainPayment`.

(cherry picked from commit f9c46e1)
`Node::splice_in` and `Node::splice_out` always contributed at our
`ChannelFunding` feerate estimate, leaving no way to choose the feerate
of a splice, unlike for on-chain sends.

Add an optional `min_fee_rate` to both. If set, it is used as the
feerate for our contribution instead of the estimate, including for
coin selection on splice-in. If unset, behavior is unchanged.

Backport of lightningdevkit#1159 (b91d018) to the 0.7 line,
where `splice_channel` takes a single feerate, so the upstream max
feerate and RBF handling don't apply.

@kaloudis kaloudis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The port reads correctly against lightningdevkit#865 and lightningdevkit#1159 for what 0.2's splice_channel supports, and splice_channel_with_fee_rate passes on the ubuntu-stable jobs. The check-cln failure is apt failing to reach security.ubuntu.com, not this change.

Inline comments below, roughly in priority order: the missing upper bound on min_fee_rate, the missing is_running check, and a test for the balance fix are the ones I'd want before merge. The rest are minor.

One item outside the diff: CHANGELOG.md has no entry for the new required min_fee_rate argument on splice_in / splice_out. It breaks the API for Rust callers and for the Swift/Kotlin/Python bindings, so it should be listed under the next release (upstream lightningdevkit#1159 added one).

Comment thread src/fee_estimator.rs Outdated
/// can't be fee-bumped, and fit the `u32` sat/kW feerate LDK takes.
pub(crate) fn is_valid_splice_fee_rate(fee_rate: FeeRate) -> bool {
let sat_per_kwu = fee_rate.to_sat_per_kwu();
sat_per_kwu >= FEERATE_FLOOR_SATS_PER_KW as u64 && sat_per_kwu <= u32::MAX as u64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This rejects rates that are too low, but the only upper limit is u32::MAX sat/kW. Since a splice can't be RBF'd or fee-bumped on 0.2, an overpay is also permanent, and on splice_out the fee comes out of the channel balance.

Example: a caller builds FeeRate::from_sat_per_vb_unchecked(2500) meaning 2500 sat/kW. That is 625,000 sat/kW, so a splice of about 800 WU pays roughly 500k sat in fees.

Upstream doesn't cap this either (its max_funding_feerate is 1.5x the caller's own rate), but since this PR already adds validation that upstream lacks, could it also reject rates above a sanity ceiling? For example MaximumFeeEstimate, or a fixed multiple of the ChannelFunding estimate. The existing unit test could get a case for it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Capped at our current MaximumFeeEstimate in f310dbe and 56c4ec6: the estimate × 1.1 + 2,500 sat/kW, or 8,000 sat/kW without one. Unit tests cover both bounds, and splice_channel_with_fee_rate now also checks a rate far above the max.

Comment thread src/lib.rs
///
/// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but
/// this classification may change in the future.
pub fn splice_in(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

splice_in and splice_out don't check is_running, while upstream's versions of both do (if !*self.is_running.read()... return Err(Error::NotRunning)). The gap predates this PR, but these are the functions being backported.

After node.stop(), splice_in still runs coin selection, insert_txo, derives a new internal address (advancing the wallet index) and calls channel_manager.splice_channel, which queues an stfu that no peer handler will flush. It returns Ok(()), so the app shows a splice that never happens. Worth adding the check to both, plus a one-line test that calls splice_in on a stopped node and expects NotRunning.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added to both in 0f89218, with splice_fails_on_stopped_node.

Comment thread src/balance.rs
// When confirmed_balance_candidate_index is 0, no specific alternative
// funding has been confirmed yet, so use the last candidate (most current
// splice/RBF attempt), matching LDK's claimable_amount_satoshis behavior.
let balance = if confirmed_balance_candidate_index != 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nothing tests the cherry-picked candidate selection. If this goes back to indexing by confirmed_balance_candidate_index (0 means the pre-splice funding), list_balances will disagree with its own total during a pending splice again, and no test will fail.

A cheap way to cover it is in run_splice_channel_test: between expect_splice_pending_event and confirmation, assert that total_lightning_balance_sats equals the sum of the ClaimableOnChannelClose amounts in lightning_balances, and that the amount reflects the post-splice value. Do this for both the splice-in and the splice-out leg.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Added in e0e9b4f and 4c5679a. Both legs of run_splice_channel_test now check, while the splice is pending, that the total matches the sum of the ClaimableOnChannelClose amounts and that it already reflects the post-splice value.

Comment thread src/lib.rs Outdated
};

let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
let fee_rate = min_fee_rate.unwrap_or_else(|| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: the pre-check further up (check_sufficient_onchain_funds(splice_amount_sats, ...)) compares spendable funds to the splice amount only. Now that the fee rate is caller-controlled it can be large, so a shortfall caused by the fee gets past that check and fails in select_confirmed_utxos with ChannelSplicingFailed.

Example: 101,000 sat spendable, splice_in of 100,000 sat at 10,000 sat/kW. The caller gets ChannelSplicingFailed, which reads like a channel or peer problem, instead of InsufficientFunds. Mapping the coin-selection failure to InsufficientFunds, or adding an estimated fee at fee_rate to the pre-check, would fix it.

@shubhamkmr04 shubhamkmr04 Nov 12, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

4cd5f63: a coin-selection shortfall now returns InsufficientFunds, and the log gives the amount and fee rate. I haven't added a test: in regtest, a fee shortfall either trips check_sufficient_onchain_funds first or gets paid from the anchor reserve. That's what lightningdevkit#1223 fixes upstream.

Comment thread src/lib.rs Outdated
};

let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
let fee_rate = min_fee_rate.unwrap_or_else(|| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor, same issue on the splice-out side: the pre-check above compares splice_amount_msat to outbound_capacity_msat without the fee, and the fee is taken from the channel balance. With 100,000 sat outbound, a 99,000 sat splice-out at 5,000 sat/kW passes the pre-check, is then rejected by LDK's contribution validation, and the caller gets a generic ChannelSplicingFailed that doesn't mention the fee. Either include an estimated fee at fee_rate in the check or log the reason so it can be diagnosed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

4cd5f63: the capacity check now logs, and LDK's rejection is logged with the amount and fee rate. I kept ChannelSplicingFailed, since LDK reports this as APIMisuseError and telling it apart would mean matching on the error text.

Comment thread src/lib.rs Outdated
///
/// If `min_fee_rate` is set, it will be used as the feerate for our contribution to the splice
/// transaction instead of a feerate estimate retrieved from the configured chain source.
/// Returns [`Error::InvalidFeeRate`] if it is below 253 sat/kW, the floor applied to our

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: the docs say InvalidFeeRate is returned only when the rate is below 253 sat/kW, but is_valid_splice_fee_rate also rejects rates above u32::MAX sat/kW (and any new upper cap). Please document both bounds here and in splice_out, so binding callers don't treat InvalidFeeRate as "too low" and retry with a higher rate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Both bounds documented on splice_in and splice_out in f310dbe.

Comment thread src/lib.rs Outdated
) -> Result<(), Error> {
let min_fee_rate = maybe_map_fee_rate_opt(min_fee_rate);
if let Some(fee_rate) = min_fee_rate {
if !is_valid_splice_fee_rate(fee_rate) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: this validate-and-log block repeats the one in splice_in, and each function still keeps its own try_into::<u32>() fallback with debug_assert!. The fallback can't trigger for a caller-supplied rate now that u32::MAX is checked. A helper such as fn resolve_splice_feerate(&self, min_fee_rate: Option<FeeRate>) -> Result<u32, Error> that validates, falls back to the ChannelFunding estimate and returns the u32 would remove both copies.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done as resolve_splice_fee_rate in f310dbe. It also removes both try_into fallbacks.

@shubhamkmr04

shubhamkmr04 commented Nov 12, 2026 •

Copy link
Copy Markdown
Author

Thanks @kaloudis ! Each comment is addressed in a separate commit, and the CHANGELOG entry, including the compatibility note, is e087a57. I'll autosquash the fixups once they are reviewed by you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants