') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Introduce FundingContributionBuilder API by wpaulino · Pull Request #4516 · lightningdevkit/rust-lightning · GitHub
Skip to content

Introduce FundingContributionBuilder API - #4516

Merged
jkczyz merged 6 commits into
lightningdevkit:mainfrom
wpaulino:funding-contribution-builder
Apr 21, 2026
Merged

Introduce FundingContributionBuilder API#4516
jkczyz merged 6 commits into
lightningdevkit:mainfrom
wpaulino:funding-contribution-builder

Conversation

@wpaulino

@wpaulinowpaulino commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

This PR refactors splice contribution construction around a new FundingBuilder API and tightens the semantics of how splice value is funded. It replaces the older FundingTemplate contribution helpers with a builder flow that can reuse, amend, or replace prior contributions for fresh splices and RBF attempts.

@wpaulinowpaulino added this to the 0.3 milestone Mar 27, 2026
@wpaulinowpaulino self-assigned this Mar 27, 2026
@ldk-reviews-bot

ldk-reviews-bot commented Mar 27, 2026

Copy link
Copy Markdown

👋 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

codecovBot commented Mar 27, 2026

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@ Coverage Diff @@## main #4516 +/- ##
==========================================
+ Coverage 86.99% 87.08% +0.08% 
==========================================
Files 163 161 -2 Lines 108635 109248 +613 Branches 108635 109248 +613 ==========================================
+ Hits 94511 95138 +627 + Misses 11647 11629 -18 - Partials 2477 2481 +4 
FlagCoverage Δ
fuzzing39.65% <34.21%> (-0.65%)⬇️
tests86.17% <84.21%> (+0.08%)⬆️

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.

@TheBlueMattTheBlueMatt left a comment

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.

basically lgtm

Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
@wpaulino
wpaulinoforce-pushed the funding-contribution-builder branch from 0968836 to 2da45efCompareMarch 30, 2026 18:54
@wpaulino
wpaulino marked this pull request as ready for review March 30, 2026 18:54
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

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

Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
.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
returnOk(FundingContribution::new(
Ok(FundingContribution::new(

Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/funding.rs Outdated
@ldk-claude-review-bot

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

Copy link
Copy Markdown
Collaborator

Review Summary — PR #4516: Introduce FundingContributionBuilder API

New issue posted this pass

  • fuzz/src/chanmon_consistency.rs:1526splice_out additive output semantics doubles the withdrawal amount on RBF calls in the fuzz test. The old splice_out_sync discarded the prior contribution; the new splice_out seeds from it and appends outputs.

Previously flagged issues (all still present)

Critical:

  1. funding.rs:570-578 — Breaking TLV tag renumbering for persisted FundingContribution.
  2. funding.rs:348splice_in/splice_in_sync additive semantics doubles value_added during RBF.

Correctness:
3. funding.rs:647 — Wrong fee estimate used in sufficiency check after dropping change output.
4. funding.rs:700-714 — No sufficiency check in the no-change fallthrough of amend_without_coin_selection.
5. funding.rs:636-644amend_without_coin_selection always fails for amended splice-out priors.
6. funding.rs:1103-1104build_from_prior_contribution failure blocks fresh splice-out fallthrough.
7. funding.rs:1076-1080FundingInputs::None silently discards prior wallet inputs when value_added == 0.
8. funding.rs:431rbf_prior_contribution now requires a prior contribution, removing old fee-bump-only capability.
9. funding.rs:1172validate_contribution_parameters blocks reuse of fee-bump-only prior contributions.
10. channel.rs:12332-12341splice_channel now fails with APIError where it previously silently degraded.

Diagnostic / DX:
11. funding.rs:1067-1073 — All feerate adjustment failures mapped to MissingCoinSelectionSource.
12. funding.rs:1292-1300saturating_add/saturating_sub for bitcoin amounts can silently truncate/zero.

Code quality:
13. funding.rs:1365,1465add_value/remove_value duplicated across async/sync builder impls.
14. funding.rs:1420,1519 — Unnecessary return keyword in final expression.

Cross-cutting concerns

  • The additive semantics of splice_in and splice_out are surprising for convenience methods. Both fuzz tests (chanmon_consistency.rs splice_in and splice_out closures) and the do_initiate_rbf_splice_in test helper are affected.
  • The MissingCoinSelectionSource error is overloaded across multiple failure modes.
  • The wallet-backed contribution design change (wallet inputs fund withdrawals) is a significant semantic shift from the old splice_in_and_out behavior.

Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated

@TheBlueMattTheBlueMatt left a comment

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 didn't look too deeply at the tests but basically LGTM.

Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs
@wpaulino
wpaulinoforce-pushed the funding-contribution-builder branch from 2da45ef to 19b230bCompareMarch 31, 2026 20:00
@wpaulino

Copy link
Copy Markdown
ContributorAuthor

Had to rebase due to a small import conflict.

Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
TheBlueMatt
TheBlueMatt previously approved these changes Apr 1, 2026

@TheBlueMattTheBlueMatt left a comment

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.

needs rebase again tho

@jkczyzjkczyz left a comment

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.

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

Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/splicing_tests.rs
@wpaulino
wpaulinoforce-pushed the funding-contribution-builder branch from de4ccfb to 97eb0fcCompareApril 9, 2026 22:17
Comment threadlightning/src/ln/channel.rs
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs
@wpaulino
wpaulinoforce-pushed the funding-contribution-builder branch from 97eb0fc to fefea5cCompareApril 10, 2026 00:05
Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/funding.rs
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs
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.
@wpaulino
wpaulinoforce-pushed the funding-contribution-builder branch from fefea5c to 33f510cCompareApril 15, 2026 02:51

@jkczyzjkczyz left a comment

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.

PR description could probably be completed now.

Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the funding-contribution-builder branch from 33f510c to fa10899CompareApril 16, 2026 16:49

@jkczyzjkczyz left a comment

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.

Mostly LGTM.

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment threadlightning/src/ln/funding.rs Outdated
Comment on lines +1116 to +1123
let total_output_value = self
.outputs
.iter()
.chain(self.change_output.iter())
.map(|txout| txout.value)
.sum::<Amount>()
.to_signed()
.expect("value_removed is validated to not exceed Amount::MAX_MONEY");

let contribution_amount = value_added - value_removed;
contribution_amount
.checked_sub(unpaid_fees)
.expect("total_output_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 see that the total input value is checked in validate_inputs, but I don't see where we validate the outputs.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

It's in validate_contribution_parameters

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@TheBlueMattTheBlueMatt left a comment

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.

Some nits on docs but I'm happy with the logic now. Will finish a full review after lunch.

pub fn value_added(&self) -> Amount {
self.value_added
let total_input_value = self.inputs.iter().map(|i| i.utxo.output.value).sum::<Amount>();
let total_output_value = self.outputs.iter().map(|output| output.value).sum::<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.

I'm confused how this differs from net_value now - they appear to be identical, do we still need both?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Mostly just used to preserve the value_added when resuming from a prior contribution. If we had a net-negative contribution, then this returns 0 rather than the negative value itself.

Comment threadlightning/src/ln/channelmanager.rs Outdated
When a user requests to add value via coin-selected inputs, we should
strive to fulfill their request. Allowing them to remove value from the
channel is undesired as it goes against their request. While we still
allow adding outputs to enabled mixed contributions, their funds must
now always come from the set of coin-selected inputs, and must never
draw from the channel balance resulting in a smaller added value.
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
wpaulinoforce-pushed the funding-contribution-builder branch from fa10899 to 9f9fe58CompareApril 20, 2026 23:34
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

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.

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

@jkczyz
jkczyz merged commit 3278ece into lightningdevkit:mainApr 21, 2026
24 of 25 checks passed
}
}
}

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.

Shouldn't we also be rejecting here if we don't have enough to meet the previously-requested target value?

@TheBlueMattTheBlueMatt left a comment

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.

One comment, otherwise post-merge ACK

@wpaulino
wpaulino deleted the funding-contribution-builder branch April 21, 2026 22:34
Sign up for freeto 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

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz