Repository navigation
Splicing: backport upstream #865 and #1159 to the 0.7 line - #15
shubhamkmr04 wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| 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>, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
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.
0cfdd34 to
0a1356b
Compare
kaloudis
left a comment
There was a problem hiding this comment.
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).
| /// 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 |
There was a problem hiding this comment.
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.
| /// | ||
| /// 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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Added to both in 0f89218, with splice_fails_on_stopped_node.
| // 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 { |
There was a problem hiding this comment.
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.
| }; | ||
|
|
||
| let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); | ||
| let fee_rate = min_fee_rate.unwrap_or_else(|| { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| }; | ||
|
|
||
| let fee_rate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); | ||
| let fee_rate = min_fee_rate.unwrap_or_else(|| { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| /// | ||
| /// 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Both bounds documented on splice_in and splice_out in f310dbe.
| ) -> 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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done as resolve_splice_fee_rate in f310dbe. It also removes both try_into fallbacks.
f945ade): while a splice is pending,list_balancesnow takes a channel's balance from the newest funding, so it matches the total.min_fee_rateonsplice_in/splice_out. 0.2'ssplice_channeltakes a single feerate, so it's used instead of the estimate. The max-feerate/RBF parts,splice_in_with_alland the fee-bump change don't apply on 0.2.f9c46e1(fee-rate helper moved toffi/mod.rs), applied by hand becauseonchain.rsdiffers here.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.min_fee_ratebelow 253 sat/kW, or too large for LDK'su32, is rejected withInvalidFeeRate. A splice can't be fee-bumped on 0.2, so a rate that's too low would leave it stuck.