Contribute to splice as acceptor#4416
Conversation
|
👋 Thanks for assigning @TheBlueMatt as a reviewer! |
fc3e1da to
758ab0a
Compare
b6cec12 to
5a26025
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #4416 +/- ##
==========================================
+ Coverage 85.94% 86.06% +0.11%
==========================================
Files 159 159
Lines 104607 105087 +480
Branches 104607 105087 +480
==========================================
+ Hits 89901 90438 +537
+ Misses 12194 12135 -59
- Partials 2512 2514 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
658f332 to
4274f75
Compare
a1fe138 to
e9c5791
Compare
lightning/src/ln/funding.rs
Outdated
| is_splice, | ||
| target_feerate, | ||
| ); | ||
| if budget < fair_fee { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
e9c5791 to
f878905
Compare
6d75f99 to
834634b
Compare
|
Latest push:
|
lightning/src/ln/funding.rs
Outdated
| /// 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 |
There was a problem hiding this comment.
It's not always the change output, it could be our channel balance in a splice out.
lightning/src/ln/funding.rs
Outdated
| // 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 { |
There was a problem hiding this comment.
Looks like we'd need to know the channel balance here to see if we can afford going higher?
There was a problem hiding this comment.
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.
lightning/src/ln/funding.rs
Outdated
| } | ||
| } else { | ||
| // No change output. | ||
| let fair_fee = estimate_transaction_fee( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
4249f19 to
0c2106b
Compare
TheBlueMatt
left a comment
There was a problem hiding this comment.
A handful of claude nits, and one or two suggestions, but LGTM either way.
lightning/src/ln/funding.rs
Outdated
| /// The minimum fee rate for the splice transaction, used to propose as initiator. | ||
| min_feerate: FeeRate, | ||
|
|
||
| /// The maximum fee rate to accept as acceptor before rejecting the splice. |
There was a problem hiding this comment.
| /// The maximum fee rate to accept as acceptor before rejecting the splice. | |
| /// The maximum fee rate to accept as acceptor before declining to add our own inputs/outputs to the splice |
There was a problem hiding this comment.
Likewise here about the RBF PR.
lightning/src/ln/funding.rs
Outdated
| /// 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 our fair fee exceeds the available budget (UTXO | ||
| /// inputs for splice-in, or channel balance for splice-out). The splice should be rejected. | ||
| TooHigh { target_feerate: FeeRate, max_feerate: FeeRate, fair_fee: Amount, budget: Amount }, | ||
| /// Arithmetic overflow when computing the available budget. | ||
| BudgetOverflow, | ||
| /// The available budget is insufficient to cover the required fees. For splice-in, the budget | ||
| /// comes from UTXO inputs; for splice-out, it comes from the channel balance. The | ||
| /// counterparty's splice can proceed without our contribution. | ||
| BudgetInsufficient { available: Amount, required: Amount }, | ||
| /// Fee surplus exceeds dust limit and cannot be absorbed without a change output. |
There was a problem hiding this comment.
Most of these docs don't seem to add anything and some are subtly wrong (eg TooLow says that we will retry via RBF, but, no, the downstream dev may retry).
There was a problem hiding this comment.
Eh, yeah I had Claude write this on top of my RBF PR initially, so my fault for not updating the docs. Though that is what will happen when we have RBF. Updated for now.
lightning/src/ln/funding.rs
Outdated
| is_splice, | ||
| target_feerate, | ||
| ); | ||
| if self.estimated_fee < fair_fee { |
There was a problem hiding this comment.
heh this is confusing. Its also entirely duplicative with the new block at the top, so might make sense to remove this block?
| if self.estimated_fee < fair_fee { | |
| if fair_fee > self.estimated_fee { |
There was a problem hiding this comment.
The earlier block is only hit if the target fee rate exceeds our max fee rate. That check allows our contribution if we pay less in fees despite the fee rate being higher (i.e., because we aren't paying for common fields and shared inputs / outputs). Re-wrote the docs there for clarity.
There was a problem hiding this comment.
Oh right I forgot the first check also rejected only on fee above our max. I don't see why we should fail here, though? Just because we don't have a change output doesn't mean we should reject any increase in total fee we paid - the user passed in a max-fee parameter, ISTM we should honor that as long as its possible to build the transaction at all (which it is as long as we have enough balance in our channel to cover whatever outputs we wanted).
There was a problem hiding this comment.
Hmm... not really from the channel balance in practice, though, but from value_added, right? If more than value_added is required then we are no longer splicing anything in. Maybe mixed mode needs to be supported, but @wpaulino is looking into those use cases.
Still it seems we are going against the users intent. Namely, they asked to add X sats to the channel and are getting less. It does make sense to include any dust previously going to fees at least, though, as if it were change. This would be consistent with the with-change case.
Anyhow, wouldn't it be better to forgo the contribution and possibly let the user explicitly RBF instead of consuming any of value_added?
Thoughts? PTAL
There was a problem hiding this comment.
Mmm, yea, fair enough, I'm fine with the new code. Can we combine the new doc paragraph with the discussion of max_feerate? ISTM there should be a discussion of the fee-related reasons why we reject joining a splice.
There was a problem hiding this comment.
Fixup for this, though not sure if it is what you had in mind.
| self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; | ||
|
|
||
| let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { | ||
| self.take_queued_funding_contribution() |
There was a problem hiding this comment.
ISTM this can be cleaned up a bit by just explicitly failing any pending splice if our counterparty's splice goes through without us contributing - we'll ultimately fail it anyway once we get through to initializing our response-splice which our counterparty should reject (unless we're doing 0conf splices, but I'm not sure we shouldn't reject the splice anyway so that the downstream dev can explicitly opt into "locking" the counterparty-initiated splice in place via 0conf).
There was a problem hiding this comment.
We currently wait until the splice finishes before retrying quiescence. Why would our counterparty reject?
In the follow-up PR for RBF, we examine the error to determine the action:
- initiate RBF if the fee rate is too high,
- fail / disconnect if the fee rate is too low, or
- let the splice go though if we can't pay for the fees from change or channel balance
There was a problem hiding this comment.
We currently wait until the splice finishes before retrying quiescence
But that's not enough - we have to wait until the splice locks, which will be a long time.
In the follow-up PR for RBF, we examine the error to determine the action:
Mmm, fair, happy to wait on this till a later PR.
lightning/src/ln/splicing_tests.rs
Outdated
| do_test_splice_both_contribute_tiebreak(None, None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_splice_tiebreak_higher_feerate() { | ||
| // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is | ||
| // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds. | ||
| let floor = FEERATE_FLOOR_SATS_PER_KW as u64; | ||
| do_test_splice_both_contribute_tiebreak( |
There was a problem hiding this comment.
Should these tests sit next to do_test_splice_both_contribute_tiebreak?
There was a problem hiding this comment.
Ah, I refactored the RBF tests to use a do_test_ helper but neglected these. Now both test_splice_tiebreak_lower_feerate and test_splice_tiebreak_feerate_too_high are DRY'ed up to use the helper, which now has some extra parameters. Helper now sits after all of them.
0c2106b to
d953cdd
Compare
lightning/src/ln/funding.rs
Outdated
| FeeRateAdjustmentError::FeeBufferInsufficient { available, required } => { | ||
| write!( | ||
| f, | ||
| "Fee buffer {} from change or channel balance is insufficient for required fee {}", |
There was a problem hiding this comment.
We don't have a concept of a "fee buffer" in the public API either? It would be nice to just spell out what went wrong based on what the public API is.
There was a problem hiding this comment.
Do you have a recommendation for the variant naming? Or should we just define "fee buffer" in the public API? It's nice to have terms that aren't overly verbose to use for conveying an idea like this, so added that to ChannelManager::splice_channel docs for now.
There was a problem hiding this comment.
Can't say I'm a fan of defining a term to only use it in logs? We aren't space-constrained in logs, we can just write out what went wrong, whether we use a term in the variant name or not.
lightning/src/ln/funding.rs
Outdated
| is_splice, | ||
| target_feerate, | ||
| ); | ||
| if self.estimated_fee < fair_fee { |
There was a problem hiding this comment.
Oh right I forgot the first check also rejected only on fee above our max. I don't see why we should fail here, though? Just because we don't have a change output doesn't mean we should reject any increase in total fee we paid - the user passed in a max-fee parameter, ISTM we should honor that as long as its possible to build the transaction at all (which it is as long as we have enough balance in our channel to cover whatever outputs we wanted).
| self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?; | ||
|
|
||
| let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() { | ||
| self.take_queued_funding_contribution() |
There was a problem hiding this comment.
We currently wait until the splice finishes before retrying quiescence
But that's not enough - we have to wait until the splice locks, which will be a long time.
In the follow-up PR for RBF, we examine the error to determine the action:
Mmm, fair, happy to wait on this till a later PR.
a9a498d to
3e8a87b
Compare
|
Rebased and needed to fix some tests / compilation errors. |
TheBlueMatt
left a comment
There was a problem hiding this comment.
Feel free to squash.
lightning/src/ln/funding.rs
Outdated
| } => { | ||
| write!( | ||
| f, | ||
| "Target feerate {} exceeds our maximum {} and fair fee {} exceeds fee buffer {}", |
There was a problem hiding this comment.
Here too what is the "fair fee" - its not in the public docs it doesn't even describe a concept to me. Can we just call it "new fee"? Also this is inconsistent use of the new "fee buffer" term - here we're not running over our fee buffer we're running over the fee we previously wanted to use.
There was a problem hiding this comment.
Here too what is the "fair fee" - its not in the public docs it doesn't even describe a concept to me. Can we just call it "new fee"?
Renamed "fair fee" everywhere to "target fee". Also had Claude re-write the docs using the splice_channel docs as a reference.
Also this is inconsistent use of the new "fee buffer" term - here we're not running over our fee buffer we're running over the fee we previously wanted to use.
Hmmm... right fee_buffer shouldn't have been used here. Fixed.
|
Heh, CI failure unrelated: "FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory" |
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>
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>
3e8a87b to
5647415
Compare
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>
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>
5647415 to
6c10054
Compare
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
FundingContributionwas 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 surplusreturned to the change output.