Skip to content

Introduce FundingContributionBuilder API#4516

Open
wpaulino wants to merge 5 commits intolightningdevkit:mainfrom
wpaulino:funding-contribution-builder
Open

Introduce FundingContributionBuilder API#4516
wpaulino wants to merge 5 commits intolightningdevkit:mainfrom
wpaulino:funding-contribution-builder

Conversation

@wpaulino
Copy link
Copy Markdown
Contributor

Looking for initial feedback on the design, still needs to be cleaned up a good bit.

@wpaulino wpaulino added this to the 0.3 milestone Mar 27, 2026
@wpaulino wpaulino requested review from TheBlueMatt and jkczyz March 27, 2026 00:05
@wpaulino wpaulino self-assigned this Mar 27, 2026
@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Mar 27, 2026

👋 Thanks for assigning @TheBlueMatt 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.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 27, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.08%. Comparing base (5704e8e) to head (de4ccfb).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4516      +/-   ##
==========================================
+ Coverage   86.99%   87.08%   +0.08%     
==========================================
  Files         163      163              
  Lines      108635   108936     +301     
  Branches   108635   108936     +301     
==========================================
+ Hits        94511    94869     +358     
+ Misses      11647    11587      -60     
- Partials     2477     2480       +3     
Flag Coverage Δ
fuzzing 40.50% <73.91%> (+0.19%) ⬆️
tests 86.16% <100.00%> (+0.07%) ⬆️

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.

Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

basically lgtm

@wpaulino wpaulino force-pushed the funding-contribution-builder branch from 0968836 to 2da45ef Compare March 30, 2026 18:54
@wpaulino wpaulino marked this pull request as ready for review March 30, 2026 18:54
@wpaulino wpaulino requested review from TheBlueMatt and jkczyz March 30, 2026 18:54
@wpaulino
Copy link
Copy Markdown
Contributor Author

Leaving the explicit input support for a follow-up as this PR is large enough already.

.await
.map_err(|_| FundingContributionError::CoinSelectionFailed)?;

return Ok(FundingContribution::new(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: unnecessary return in final expression position. Same on line 740 for the sync variant.

Suggested change
return Ok(FundingContribution::new(
Ok(FundingContribution::new(

@ldk-claude-review-bot
Copy link
Copy Markdown
Collaborator

ldk-claude-review-bot commented Mar 30, 2026

Review Summary — PR #4516: Introduce FundingContributionBuilder API (Pass 3)

New issues found this pass

These are issues not covered by any prior review pass:

Inline comments posted

  • lightning/src/ln/funding.rs:1046-1055build_from_prior_contribution maps all feerate adjustment failures to MissingCoinSelectionSource, losing diagnostic information and causing silent input set replacement when a wallet is attached.
  • lightning/src/ln/funding.rs:1085-1107try_build_without_coin_selection never reaches the fresh splice-out path when a prior contribution exists, because build_from_prior_contribution returns early. This prevents no-wallet splice-out amendments. (Concretizes the suggestion from prior pass with a code sketch.)
  • lightning/src/ln/funding.rs:1058-1062FundingInputs::None silently discards prior wallet inputs when value_added == 0, which is surprising when a user calls remove_value to reduce an existing splice-in to zero.

Previously flagged issues (still present)

  1. funding.rs:553-561 — Breaking TLV tag renumbering for persisted FundingContribution.
  2. funding.rs:335-340splice_in/splice_in_sync additive semantics footgun during RBF (confirmed do_initiate_rbf_splice_in at splicing_tests.rs:209 hits this).
  3. funding.rs:1278-1280saturating_add/saturating_sub for bitcoin amounts.
  4. funding.rs:1345-1365,1445-1465add_value/remove_value duplicated across async/sync builder impls.
  5. funding.rs:1406,1505 — Unnecessary return keyword in final expression.
  6. funding.rs:403-416rbf_prior_contribution requires prior contribution, removing old fee-bump-only capability.
  7. funding.rs:1158validate_contribution_parameters blocks reuse of fee-bump-only prior contributions.
  8. funding.rs:515-524FundingInputs single-variant enum (besides None) adds indirection.
  9. channel.rs:12332-12341splice_channel now fails with APIError where it previously degraded silently.
  10. funding.rs:663-667 — Wrong fee estimate used when checking sufficiency after dropping change.
  11. funding.rs:617-620,636-644with_inputs_and_outputs always fails for amended splice-out priors.

Cross-cutting concerns

  • The MissingCoinSelectionSource error is overloaded: it signals both "need wallet inputs to satisfy this request" and "feerate adjustment failed on the prior." For no-wallet builders this manifests as an unhelpful error; for wallet-backed builders it silently discards prior inputs via coin selection fallthrough. A more granular error would improve debuggability and give callers the option to handle feerate-related failures differently from input-insufficiency failures.

Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

I didn't look too deeply at the tests but basically LGTM.

@wpaulino wpaulino force-pushed the funding-contribution-builder branch from 2da45ef to 19b230b Compare March 31, 2026 20:00
@wpaulino
Copy link
Copy Markdown
Contributor Author

Had to rebase due to a small import conflict.

@wpaulino wpaulino requested review from TheBlueMatt and jkczyz March 31, 2026 20:29
TheBlueMatt
TheBlueMatt previously approved these changes Apr 1, 2026
Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

needs rebase again tho

Copy link
Copy Markdown
Contributor

@jkczyz jkczyz left a comment

Choose a reason for hiding this comment

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

Code looks good, but I think merging some impl blocks will help the diff.

Comment on lines 534 to 542
impl_writeable_tlv_based!(FundingContribution, {
(1, value_added, required),
(3, estimated_fee, required),
(5, inputs, optional_vec),
(7, outputs, optional_vec),
(9, change_output, option),
(11, feerate, required),
(13, max_feerate, required),
(15, is_splice, required),
(1, estimated_fee, required),
(3, inputs, optional_vec),
(5, outputs, optional_vec),
(7, change_output, option),
(9, feerate, required),
(11, max_feerate, required),
(13, is_splice, required),
});
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Breaking serialization: TLV tags renumbered for a persisted type

FundingContribution is serialized as part of PendingFunding.contributions (channel.rs:2926), which is persisted in FundedChannel (channel.rs:15814, tag 64). Renumbering the TLV tags means any previously serialized FundingContribution will fail to deserialize:

  • Old tag 1 = value_added (Amount), new tag 1 = estimated_fee (Amount) — same type, wrong semantics
  • Old tag 3 = estimated_fee (Amount), new tag 3 = inputs (Vec) — different types, will fail

If a node with a pending splice upgrades, it will be unable to read its channel data.

Options:

  1. Keep the old tag numbering and make value_added optional (defaulting to derived) for backward compatibility
  2. Use new, higher tag numbers (e.g., start at 17+) so old fields are simply ignored by the new code
  3. If splicing serialization is considered unstable/unreleased, document this as a known breaking change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This serialization has not been included in a release so it should be safe to break.

@TheBlueMatt
Copy link
Copy Markdown
Collaborator

linter, fuzz-sanity, and check_commits are failing.

Comment on lines +1047 to +1051
holder_balance.ok_or(FeeRateAdjustmentError::FeeBufferInsufficient {
source: "channel balance",
available: Amount::ZERO,
required: target_fee,
})?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you add a test for this error path?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm... this comment was made on the second commit, but it shows up in a weird place on the third commit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a unit test since a functional test would be pretty hard to discern. It's also quite the edge case, you shouldn't have a net-negative contribution in the first place if you didn't have a balance, so either the contribution is stale/invalid or the balance is.

/// The value that will be added to the channel after fees. See [`Self::net_value`] for the net
/// value contribution to the channel.
fn value_added(&self) -> Amount {
let total_input_value = self.inputs.iter().map(|i| i.utxo.output.value).sum::<Amount>();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should we check if this overflows?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We check it when we select the inputs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Where is that? I don't see it checked after calling select_confirmed_utxo. Maybe we shouldn't care if the user's CoinSelectionSource is broken?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah somehow this part didn't make it into this commit and was in my follow-up for manual input support. Should be good now.

return Err(FeeRateAdjustmentError::FeeBufferInsufficient {
source: "channel balance - withdrawal outputs",
available: holder_balance.checked_sub(value_removed).unwrap_or(Amount::ZERO),
source: "channel balance",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Isn't the previous wording correct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Only when there aren't any inputs because there may be some that are partially contributing to the net-negative contribution.

.to_signed()
.expect("value_added is validated to not exceed Amount::MAX_MONEY");
let value_removed = self
.expect("total_input_value is validated to not exceed Amount::MAX_MONEY");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I believe the validation was removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Everything should be in validate_contribution_parameters, inputs are validated separately when we do coin selection.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I meant can't the sum overflow? We avoided the problem earlier with the explicit value_added. But maybe it was already a problem elsewhere?

let estimated_fee = self
.estimated_fee
.to_signed()
.expect("total_input_value is validated to not exceed Amount::MAX_MONEY");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Likewise.

It does not require coin selection, so the wallet argument is not
necessary.
@wpaulino wpaulino force-pushed the funding-contribution-builder branch from c11af28 to de4ccfb Compare April 8, 2026 17:51
}
}

if self.value_added == Amount::ZERO && self.outputs.is_empty() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correctness concern: validate_contribution_parameters blocks reuse of fee-bump-only prior contributions

When a prior contribution has value_added() == 0 and empty outputs (a fee-bump-only contribution that only selected wallet inputs to pay fees), initializing a builder from it yields self.value_added = 0 and self.outputs = []. The validate_contribution_parameters check here then rejects the request with InvalidSpliceValue before build_from_prior_contribution gets a chance to reuse the prior.

This can happen when coin selection returned inputs where inputs - fee - change = 0 exactly. The old rbf code handled this case (via force_coin_selection = true); the new builder path rejects it at validation.

This may be intentional given the removal of the fee-bump-only flow, but it creates an asymmetry: a contribution that was successfully built and stored as a prior can fail to be reused with an identical request, which would be surprising to callers using rbf_prior_contribution or with_prior_contribution.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fee-bump-only contributions would have the prior contribution's value_added and outputs so this is nonsense.

/// Reuses the contribution's existing inputs while targeting at least `value_added` added to
/// the channel after fees. If dropping the change output leaves surplus value, it remains in
/// the channel contribution.
CoinSelected { value_added: Amount },
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: FundingInputs is a single-variant enum, adding indirection without benefit

FundingInputs has only one variant (CoinSelected), yet with_inputs_and_outputs and amend_without_coin_selection match on it. The target_value_added extracted from the match is Option<Amount> but is always Some(...) in practice. This makes the control flow harder to follow without adding extensibility — if new variants are expected soon, a comment noting that would help; otherwise this could be simplified to a plain struct or direct parameter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We plan to add another variant in a follow-up for manual input support.

let new_fee = new_estimated_fee
.to_signed()
.expect("total input amount cannot exceed Amount::MAX_MONEY");
let new_change = new_change
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does this need to consider dust?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

new_change is returned from compute_feerate_adjustment so it should have already been considered there.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think that's quite sufficient? This is also called from net_value_for_acceptor_at_feerate in channel.rs's resolve_queued_contribution. The resulting our_funding_contribution is sent to our peer. Maybe its fine to be a bit off there but it does leave a good chunk of code all the way into channel.rs kinda confusing.

target_feerate,
);
let net_value_without_fee = self.net_value_without_fee();
if net_value_without_fee.is_positive() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Its weird to split on whether the net contribution was negative or not - if I have a splice where I added 1 BTC+1 sat to the channel but sent 1BTC out without change output, I'm probably more than happy to RBF by using the value of some of my inputs to change the channel's balance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, but then we're not really abiding by the value_added they provided when we initially coin-selected the inputs. With manual input selection, this will work out of the box since there's no explicit value_added there, we're just always adding whatever is left after fees. Should we treat the coin-selected no-change case the same way?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With manual input selection, this will work out of the box since there's no explicit value_added there, we're just always adding whatever is left after fees.

Hmm? There's no explicit value_added anywhere now? So I suppose you could argue this code is correct for the coin selection case but wrong for the all-input case? Do we need to track the input selection style here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's still explicit when splicing in funds via FundingBuilder::add_value. The manual input selection does have a special case in this path to allow spending up to half of the UTXO value (as long as the feerate is still within the max of course).

Comment on lines 2533 to 2535
// When splice_out_sync is called on a template with min_rbf_feerate set (user
// choosing a fresh splice-out instead of rbf_sync), coin selection should NOT run.
// Fees come from the channel balance.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Comment needs updating to remove _sync in a couple places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's already fixed in a later commit

let (value_added, outputs) = match prior_contribution.as_ref() {
Some(prior) => {
let outputs = prior.contribution.outputs.clone();
(prior.contribution.value_added(), outputs)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the prior contribution had added value but had dust absorbed effectively increasing that value by more than requested, will that be a problem when then using with_prior_contribution chained with either add_value or remove_value?

Seems the intent could be to remove the original added value, but a call to remove_value with the same value wouldn't get it all. Likewise, adding more value may cause different input selection which would need to account for the added value from the dust.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It shouldn't be an issue. I don't think you'd want to blindly add/remove without confirming what was already there (removing is a no-op if nothing was previously added), so they'd see they have more than they expected and can remove it all or add the remainder to their desired total if they wish.

wpaulino added 2 commits April 9, 2026 15:13
The `holder_balance` is computed by
`FundedChannel::get_holder_counterparty_balances_floor_incl_fee`, which
may unexpectedly fail due to the balance either being too high or too
low. These cases are highly unlikely to happen given we have validation
to ensure we never enter such a state to begin with. If they were to
happen, something has gone wrong with the channel and it doesn't make
sense to allow splicing anyway. Therefore, we opt to make
`PriorContribution::holder_balance` non-optional and return an error
that the channel cannot be spliced at the moment.
This commit removes `FundingContribution::value_added` as tracking it is
unnecessary -- it can just be derived from the total amount in minus
total amount out minus fees. Doing so also highlighted that there was an
incorrect assumption in how feerates are computed/adjusted. Ultimately,
we don't care whether a contribution has inputs or not. Instead, we look
at whether it's contributing a positive or negative amount to the
channel, and enforce different constraints for each.
@wpaulino wpaulino force-pushed the funding-contribution-builder branch from de4ccfb to 97eb0fc Compare April 9, 2026 22:17
Comment on lines +12332 to +12341
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Behavior change: splice_channel now fails where it previously silently degraded

The old build_prior_contribution used .ok() to convert a balance computation error to None, building a PriorContribution { holder_balance: None }. The RBF path would then skip the feerate-adjustment fast path and fall through to coin selection.

The new code propagates the error via ?, failing the entire splice_channel call with APIError::ChannelUnavailable. This means RBF scenarios where the balance can't be computed (e.g., channel in a transient state) will now error instead of degrading gracefully to a re-coin-selection path.

This is arguably more correct (fail-fast), but it's a user-visible behavior change that should be intentional. If this is intentional, consider a brief comment noting the rationale.

Comment on lines +496 to +499
let message_len = MESSAGE_TEMPLATE.serialized_length() + input.prevtx.serialized_length();
(message_len <= LN_MAX_MSG_LEN)
.then(|| ())
.ok_or(FundingContributionError::InvalidSpliceValue)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Misleading error variant for prevtx-too-large condition

InvalidSpliceValue is returned when a prevtx exceeds LN_MAX_MSG_LEN, but the splice value isn't actually invalid — the previous transaction referenced by the input is too large. The old validate() method returned a descriptive string: "Funding input references a prevtx that is too large for tx_add_input: {outpoint}".

Consider either a dedicated error variant (e.g., PrevtxTooLarge) or at least a different existing variant. This also affects debuggability — callers seeing InvalidSpliceValue will look at their amounts, not their prevtx sizes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the dedicated error variant.

Comment on lines +629 to +636
let total_input_value: Amount =
inputs.iter().map(|input| input.utxo.output.value).sum();

if self.change_output.is_none() && total_input_value < required_value {
// The prior input selection is not enough, we may need to re-attempt coin
// selection.
return None;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

with_inputs_and_outputs always fails for amended splice-out priors

For a splice-out prior contribution (no inputs, no change), if the user adds or changes outputs, build_from_prior_contribution calls amend_without_coin_selection which calls this method. Here total_input_value = 0 and required_value = 0 + estimated_fee > 0, so the self.change_output.is_none() && total_input_value < required_value guard returns None.

This prevents any amendment to splice-out priors (e.g., adding a withdrawal output) even though splice-out fees are paid from channel balance, not inputs. The only way to modify a splice-out request with a prior contribution is without_prior_contribution().

The splice_out() convenience method uses with_prior_contribution and calls add_outputs, so it would hit this limitation in the RBF case. The method's docs do mention MissingCoinSelectionSource is possible, but the underlying issue is that with_inputs_and_outputs doesn't account for channel-balance-funded fees.

Consider falling through to the fresh splice-out construction in try_build_without_coin_selection when build_from_prior_contribution fails and value_added == 0.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a FundingInputs::None variant to address this.

wpaulino added 2 commits April 9, 2026 17:05
This lets callers easily amend a prior contribution in place and only
re-run coin selection when the new request cannot be satisfied with the
existing inputs.
This results in a slight change of behavior: now these methods reuse and
amend the prior contribution, as opposed to always starting from a fresh
contribution, which would be the desired expected behavior by users.
@wpaulino wpaulino force-pushed the funding-contribution-builder branch from 97eb0fc to fefea5c Compare April 10, 2026 00:05
Comment on lines +1046 to +1055
if self.request_matches_prior(&contribution) {
// Same request, but the feerate may have changed. Adjust the prior contribution
// to the new feerate if possible.
return contribution
.for_initiator_at_feerate(self.feerate, holder_balance)
.map(|mut adjusted| {
adjusted.max_feerate = self.max_feerate;
adjusted
})
.map_err(|_| FundingContributionError::MissingCoinSelectionSource);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Misleading error mapping: feerate-related failures become MissingCoinSelectionSource

When request_matches_prior is true but for_initiator_at_feerate fails (e.g., FeeBufferInsufficient when the prior's inputs can't cover the higher fee, or FeeRateTooLow if the builder's feerate is somehow below the prior's due to a stale prior), the error is mapped to MissingCoinSelectionSource.

For FundingBuilder::build() (no wallet), this surfaces as "Coin selection source required" — but the real issue might be that the user's change output can't absorb the fee increase, not that coin selection is needed. A caller seeing this error would attach a wallet, which triggers coin selection and discards the prior inputs entirely — possibly not what the user intended.

For wallet-backed builders, this MissingCoinSelectionSource error causes a fallthrough to coin selection, silently replacing the prior's input set. The user may not realize their prior inputs were discarded.

Consider preserving the underlying FeeRateAdjustmentError as a distinct variant in FundingContributionError so callers can distinguish between "needs wallet inputs" and "feerate can't be accommodated by this contribution."

Comment on lines +1085 to +1107
if let Some(contribution) = self.prior_contribution.take() {
return self.build_from_prior_contribution(contribution);
}

if self.value_added == Amount::ZERO {
let estimated_fee = estimate_transaction_fee(
&[],
&self.outputs,
None,
true,
self.shared_input.is_some(),
self.feerate,
);
return Ok(FundingContribution {
estimated_fee,
inputs: vec![],
outputs: core::mem::take(&mut self.outputs),
change_output: None,
feerate: self.feerate,
max_feerate: self.max_feerate,
is_splice: self.shared_input.is_some(),
});
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

try_build_without_coin_selection never reaches the fresh splice-out path when a prior exists

When a prior contribution is present (line 1085), build_from_prior_contribution is called and its result returned immediately. If that method returns MissingCoinSelectionSource (e.g., because amend_without_coin_selection can't handle the modified request), the fresh splice-out construction at lines 1089-1106 is never reached.

This means a user calling splice_out(new_outputs, feerate, max_feerate) on a template with a prior splice-out contribution will get MissingCoinSelectionSource even though a fresh splice-out (no inputs, fees from channel balance) would succeed. The user must explicitly use without_prior_contribution() to work around this.

Consider: when build_from_prior_contribution fails and self.value_added == 0 (no value being added), fall through to the fresh splice-out path instead of returning the error. Something like:

if let Some(contribution) = self.prior_contribution.take() {
    match self.build_from_prior_contribution(contribution) {
        Err(FundingContributionError::MissingCoinSelectionSource)
            if self.value_added == Amount::ZERO && !self.outputs.is_empty() => {},
        other => return other,
    }
}

Comment on lines +1058 to +1062
let funding_inputs = if self.value_added != Amount::ZERO {
FundingInputs::CoinSelected { value_added: self.value_added }
} else {
FundingInputs::None
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

FundingInputs::None silently discards prior inputs when value_added == 0

When the builder's value_added is 0 (either because the prior had value_added() == 0, or the user called remove_value to reduce it to 0), this uses FundingInputs::None, which causes with_inputs_and_outputs to set inputs = Vec::new(). Any wallet inputs the prior contribution selected are discarded.

This is particularly surprising for the remove_value case: a user who calls .remove_value(full_amount) might expect to keep the prior's inputs and just redirect all input value to the channel (minus fees), but instead the inputs are dropped entirely and the contribution becomes a pure splice-out.

Consider whether FundingInputs::CoinSelected { value_added: Amount::ZERO } would be more appropriate when the prior had inputs, to preserve the prior's input set and let with_inputs_and_outputs determine if they're still sufficient.

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.

5 participants