Skip to content

Contribute to splice as acceptor#4416

Open
jkczyz wants to merge 10 commits intolightningdevkit:mainfrom
jkczyz:2026-02-splice-acceptor
Open

Contribute to splice as acceptor#4416
jkczyz wants to merge 10 commits intolightningdevkit:mainfrom
jkczyz:2026-02-splice-acceptor

Conversation

@jkczyz
Copy link
Contributor

@jkczyz jkczyz commented Feb 12, 2026

When both nodes want to splice simultaneously, the quiescence tie-breaker designates one as the initiator. Previously, the losing node responded with zero contribution, requiring a second full splice session after the first splice locked. This is wasteful, especially for often-offline nodes that may connect and immediately want to splice.

Instead, the losing node contributes to the winner's splice as the acceptor, merging both contributions into a single splice transaction. Since the FundingContribution was originally built with initiator fees (which include common fields and shared input/output weight), the fee is adjusted to the acceptor rate before contributing, with the surplus
returned to the change output.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Feb 12, 2026

👋 Thanks for assigning @wpaulino as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@jkczyz jkczyz self-assigned this Feb 12, 2026
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from fc3e1da to 758ab0a Compare February 18, 2026 01:02
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch 3 times, most recently from b6cec12 to 5a26025 Compare February 23, 2026 17:16
@codecov
Copy link

codecov bot commented Feb 23, 2026

Codecov Report

❌ Patch coverage is 94.63487% with 36 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.04%. Comparing base (24062c0) to head (834634b).
⚠️ Report is 28 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/funding.rs 95.00% 15 Missing and 12 partials ⚠️
lightning/src/ln/channel.rs 92.06% 3 Missing and 2 partials ⚠️
lightning/src/ln/interactivetxs.rs 93.84% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4416      +/-   ##
==========================================
+ Coverage   85.94%   86.04%   +0.10%     
==========================================
  Files         159      159              
  Lines      104607   104978     +371     
  Branches   104607   104978     +371     
==========================================
+ Hits        89901    90327     +426     
+ Misses      12194    12142      -52     
+ Partials     2512     2509       -3     
Flag Coverage Δ
tests 86.04% <94.63%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch 2 times, most recently from 658f332 to 4274f75 Compare February 24, 2026 21:16
@jkczyz jkczyz marked this pull request as ready for review February 24, 2026 21:19
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch 2 times, most recently from a1fe138 to e9c5791 Compare February 24, 2026 22:20
is_splice,
target_feerate,
);
if budget < fair_fee {
Copy link
Contributor

Choose a reason for hiding this comment

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

If we have a mixed splice where we fully consume an input and cover the remaining outstanding amount with the channel balance, then budget should probably also reflect the channel balance available to spend?

Copy link
Contributor Author

@jkczyz jkczyz Feb 25, 2026

Choose a reason for hiding this comment

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

As of now, we've only allowed drawing from the channel balance in the splice-out case. Mixed mode will only use inputs.

Outsourcing this idea to Claude, it would be possible but has some implications. We can discuss but I'd rather leave this to a follow-up as it would change the current behavior.

❯ What are the implications of allowing the fees to be absorbed by the      
  channel balance in this case? Is it possible to do? What code changes     
  would be involved?                                                        

⏺ Let me trace how net_value flows through the protocol to understand the
  implications.

⏺ Searched for 2 patterns (ctrl+o to expand)

⏺ Now let me look at how our_funding_contribution is used in
  validate_splice_contributions and the interactive TX setup.

⏺ Searched for 1 pattern (ctrl+o to expand)

⏺ Now let me see how our_funding_contribution flows into
  FundingScope::for_splice and how it affects the shared output value.

⏺ Searched for 1 pattern (ctrl+o to expand)

⏺ This is the key piece. our_funding_contribution (which is net_value())
  directly determines:
  1. post_channel_value — the new total channel capacity
  2. post_value_to_self_msat — our balance in the new channel (line 2794-2796:
  value_to_self_msat + our_funding_contribution * 1000)

  Now let me check how the acceptor path uses net_value_for_acceptor_at_feerate
  vs net_value to understand the current split.

⏺ Read 1 file (ctrl+o to expand)

⏺ Here's what I've found:

  How net_value is used: our_funding_contribution (from net_value() or
  net_value_for_acceptor_at_feerate()) flows into FundingScope::for_splice where
   it directly determines:
  - post_channel_value — the new channel capacity
  - value_to_self_msat — our balance (line 2796: prev_value_to_self + 
  our_funding_contribution * 1000)

  Current behavior for with-inputs case: net_value_with_fee returns value_added 
  - value_removed regardless of estimated_fee, because unpaid_fees = 0 when
  inputs are present. So our_funding_contribution is fixed — feerate changes
  don't affect it.

  Current behavior for no-inputs case (pure splice-out): net_value_with_fee
  returns -value_removed - estimated_fee. The fee IS deducted from channel
  balance. And compute_feerate_adjustment in the no-inputs branch (line 504-521)
   freely allows the surplus to go back to channel balance — there's no strict
  budget constraint, just a check that estimated_fee >= fair_fee.

  The question for mixed mode: Could we let the channel balance absorb fee
  increases when inputs can't cover them?

  Yes, it's possible, but here's what it would mean:

  1. net_value would become feerate-dependent even with inputs. Instead of
  value_added - value_removed, it would be something like value_added - 
  value_removed - unpaid_fee_delta. This changes our_funding_contribution, which
   changes both the channel capacity and our balance.
  2. The user's intended contribution silently changes. If someone asks for
  splice_in_and_out(10k, [80k output]), they expect net = -70k. If we absorb 500
   sats of extra fee from channel balance, net becomes -70,500. The user's
  channel balance decreases by an extra 500 sats beyond what they requested.
  3. Code changes required:
    - compute_feerate_adjustment would need a new branch (or extension of the
  inputs branch) that, when the inputs budget is insufficient, computes how much
   extra fee the channel balance can absorb
    - net_value_with_fee would need to change: instead of unpaid_fees = 0 when
  inputs are present, it would need to compute the portion of fees not covered
  by inputs
    - for_acceptor_at_feerate would need to propagate this adjusted net value
    - net_value_for_acceptor_at_feerate already uses
  net_value_with_fee(new_estimated_fee) — this would need to account for the
  split between input-paid and balance-paid fees
    - Tests asserting net_value is stable across feerate changes for splice-in
  would need updating
  4. Protocol implication: The splice_ack.funding_contribution_satoshis message
  communicates our net contribution. If our contribution changes due to the
  initiator's feerate, the value we send in splice_ack must reflect the adjusted
   amount. Currently splice_init computes the adjusted our_funding_contribution
  via net_value_for_acceptor_at_feerate before sending splice_ack, so the
  plumbing exists — it just returns the same value today for the with-inputs
  case.

  Bottom line: It's technically feasible but it changes the semantics of the
  user's contribution. The current behavior (fail if inputs can't cover the fee
  increase) is arguably more correct — it tells the user "your contribution
  can't accommodate this feerate" rather than silently taking more from their
  channel balance. The pure splice-out case is different because there, fees
  from channel balance are the only fee mechanism, so flexibility is expected.

  Does this answer your question, or are you considering making a change here?

✻ Baked for 49s

.ok_or("Budget plus change value overflow".to_string())?;

match available.checked_sub(fair_fee) {
Some(new_change_value) if new_change_value >= dust_limit => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

In both the no-input and no-change case we fail if we'd pay a fee higher than our initially-estimated fee, but here we'll happily continue even if we'd end up reducing change or even removing it. ISTM we should maybe fail instead, as we'd maybe prefer to do our own splice with our own feerate on top of the counterparty's splice later or even splice on another channel instead instead of paying more in fees. Its complicated though, for someone with only a single channel who wants to pay immediately they'd prefer it to work, but in that setup there's a chance that the counterparty just beats them to the punch anyway, so maybe its "something they have to deal with either way" and its fine?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, related to my response to one of @wpaulino's comments, perhaps the user needs to indicate a max fee rate for these scenarios? Otherwise, it seems we'd never contribute as an acceptor.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, note that if the user-specified min/max fee rate is tight, we could reject if the counterparty gives a fee rate slightly above the max even though we may pay less fees since we wouldn't be paying for common fields and shared input / output. So that would need to be considered.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated now. PTAL.

@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from e9c5791 to f878905 Compare February 25, 2026 18:12
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from f878905 to 6d75f99 Compare February 26, 2026 00:47
Now that the Splice variant (containing non-serializable
FundingContribution) is the only variant produced, and the previous
commit consumes the acceptor's quiescent_action in splice_init(),
there is no longer a need to persist it. This allows removing
LegacySplice, SpliceInstructions, ChangeStrategy, and related code
paths including calculate_change_output, calculate_change_output_value,
and the legacy send_splice_init method.

With ChangeStrategy removed, the only remaining path in
calculate_change_output was FromCoinSelection which always returned
Ok(None), making it dead code. The into_interactive_tx_constructor
method is simplified accordingly, and the signer_provider parameter
is removed from it and from splice_init/splice_ack since it was only
needed for the removed change output calculation.

On deserialization, quiescent_action (TLV 65) is still read for
backwards compatibility but discarded, and the awaiting_quiescence
channel state flag is cleared since it cannot be acted upon without
a quiescent_action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from 6d75f99 to 834634b Compare February 27, 2026 00:31
@jkczyz
Copy link
Contributor Author

jkczyz commented Feb 27, 2026

Latest push:

  • squashes fixups
  • fixes fuzz and lint CI failures
  • makes FeeRateAdjustmentError more structured so we don't need to allocate a string just to print the error

pub fn splice_channel(
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, feerate: FeeRate,
&self, channel_id: &ChannelId, counterparty_node_id: &PublicKey,
min_feerate: FeeRate, max_feerate: FeeRate,
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's add docs for why we require both min and max

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

/// Target feerate is below our minimum. The counterparty's splice can proceed without our
/// contribution; we'll retry via RBF at our preferred feerate.
TooLow { target_feerate: FeeRate, min_feerate: FeeRate },
/// Target feerate is above our maximum and would consume too much of the change output. The
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not always the change output, it could be our channel balance in a splice out.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated all docs.

// No inputs (splice-out): fees paid from channel balance.
let fair_fee =
estimate_transaction_fee(&[], &self.outputs, false, is_splice, target_feerate);
if self.estimated_fee < fair_fee {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like we'd need to know the channel balance here to see if we can afford going higher?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, you're right. Re-worked though when get_holder_counterparty_balances_floor_incl_fee returns an error we skip the contribution. Not sure if you prefer something else. For RBF, we can't really skip if it's our previous contribution, so we'd have to fail the RBF.

}
} else {
// No change output.
let fair_fee = estimate_transaction_fee(
Copy link
Contributor

Choose a reason for hiding this comment

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

In practice, this case should never work out, right? Not having a change output implies that we had a ~perfect match, so even a slight change in feerate would either result in needing another input or needing a change output.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The change could have been burned to the miner and possibly could still be if only slightly different. The feerate isn't the only thing that matters though, as an acceptor you pay for less weight than if you are the initiator. Added a test exercising this code path.

jkczyz and others added 3 commits February 27, 2026 17:27
Replace the single public InteractiveTxConstructor::new() with separate
new_for_outbound() and new_for_inbound() constructors. This moves the
initiator's first message preparation out of the core constructor,
making it infallible and removing is_initiator from the args struct.

Callers no longer need to handle constructor errors, which avoids having
to generate SpliceFailed/DiscardFunding events after the QuiescentAction
has already been consumed during splice_init/splice_ack handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a `change_output: Option<&TxOut>` parameter to
`estimate_transaction_fee` so the initial fee estimate accounts for the
change output's weight. Previously, the change output weight was omitted
from `estimated_fee` in `FundingContribution`, causing the estimate to
be slightly too low when a change output was present.

This also eliminates an unnecessary `Vec<TxOut>` allocation in
`compute_feerate_adjustment`, which previously cloned outputs into a
temporary Vec just to include the change output for the fee estimate.

A mock `TightBudgetWallet` is added to `splicing_tests` to demonstrate
that `validate()` correctly rejects contributions where the input value
is sufficient without the change output weight but insufficient with it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jkczyz and others added 3 commits February 27, 2026 20:41
When constructing a FundingContribution, it's always assumed the
estimated_fee is for when used as the initiator, who pays for the common
fields and shared inputs / outputs. However, when the contribution is
used as the acceptor, we'd be overpaying fees.

Additionally, the initiator's chosen fee rate may not be compatible with
the acceptors contributions. The selected UTXOs may not be enough to pay
for a higher feerate (i.e., the change output is not enough to pay or
there is no change output).

This change provides a method on FundingContribution for adjusting the
fee rate with the above concerns in mind. It also updates it to include
a max_feerate specified by the user when initiating a splice. This
ensures the acceptor isn't forced to pay an overly high fee rate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
For splice-out (no contributed inputs), fees are paid from the channel
balance. Previously, compute_feerate_adjustment only checked fees
against estimated_fee (a weight-based budget), which isn't a meaningful
cap for splice-out since no coin selection occurs. This could cause
the fee adjustment to succeed even when the channel balance can't cover
fair_fee + value_removed, leading to a hard ChannelError later in
validate_splice_contributions instead of a graceful contribution-drop
in splice_init.

Replace the estimated_fee check in the no-inputs branch with a channel
balance check that validates the total cost (fair_fee + outputs) against
the holder's actual balance. The holder_balance is computed in
splice_init from get_holder_counterparty_balances_floor_incl_fee and
passed through the call chain. If the balance can't be computed, the
contribution is dropped early rather than proceeding without the check.

Also update FeeRateAdjustmentError docs to accurately describe both
splice-in (UTXO inputs) and splice-out (channel balance) cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cover the success path where inputs are present, no change output,
estimated_fee >= fair_fee, and surplus < dust_limit. Uses a realistic
feerate scenario: acceptor budgets at 1,000 sat/kwu (initiator weight)
and the initiator proposes 3,000 sat/kwu, so the acceptor's fair fee
nearly matches the original budget with a small sub-dust surplus.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from 834634b to 4249f19 Compare February 28, 2026 02:53
jkczyz and others added 3 commits February 27, 2026 20:56
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When both nodes want to splice simultaneously, the quiescence
tie-breaker designates one as the initiator. Previously, the losing
node responded with zero contribution, requiring a second full splice
session after the first splice locked. This is wasteful, especially for
often-offline nodes that may connect and immediately want to splice.

Instead, the losing node contributes to the winner's splice as the
acceptor, merging both contributions into a single splice transaction.
Since the FundingContribution was originally built with initiator fees
(which include common fields and shared input/output weight), the fee is
adjusted to the acceptor rate before contributing, with the surplus
returned to the change output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use a single get_and_clear_pending_msg_events() + match pattern for the
initiator's turn, matching the existing acceptor code path. Also add
assertions that all expected initiator inputs and outputs were sent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-02-splice-acceptor branch from 4249f19 to 0c2106b Compare February 28, 2026 02:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants