Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

@wpaulino@ldk-reviews-bot@ldk-claude-review-bot@TheBlueMatt@jkczyz
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Support async signing of splice shared input - #4579

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input
May 13, 2026
Merged

Support async signing of splice shared input#4579
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
wpaulino:async-sign-shared-input

Conversation

@wpaulino

@wpaulinowpaulino commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

While user signatures may be provided whenever ready at the user's discretion when handling a FundingTransactionReadyForSigning event, it does not cover the user's signature for the 2-of-2 multisig input in a splice. This signature is obtained via the EcdsaChannelSigner, which did not support providing it asynchronously.

Since the splice shared input signature is part of the tx_signatures message, we're not allowed to send the message until it's complete. This results in us needing to explicitly handle the signature exchange logic when the signer unblocks the shared input signature.

Fixes#4533.

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

ldk-reviews-bot commented Apr 29, 2026

Copy link
Copy Markdown

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

Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment on lines +13990 to +14001
if let Some((splice_tx, tx_type)) = msgs
.funding_tx_signed
.as_mut()
.and_then(|funding_tx_signed| funding_tx_signed.funding_tx.take())
{
debug_assert!(matches!(tx_type, TransactionType::Splice { .. }));
log_info!(
logger,
"Broadcasting signed splice transaction with txid {}",
splice_tx.compute_txid(),
);
self.tx_broadcaster.broadcast_transactions(&[(&splice_tx, tx_type)]);

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: The variable name splice_tx and the debug_assert!(matches!(tx_type, TransactionType::Splice { .. })) assume this funding_tx is always a splice transaction. While this assertion is correct for all currently reachable paths (V2 initial funding cannot reach on_tx_signatures_exchange from signer_maybe_unblocked because the counterparty can't send tx_signatures before receiving our commitment_signed), the FundingTxSigned struct is generic enough to carry either type. If V2 dual-funding support evolves and this path becomes reachable for initial funding, the assert would fire and emit_channel_pending_event! would be missing (unlike the internal_tx_signatures handler which calls broadcast_interactive_funding).

Consider either renaming splice_txfunding_tx and handling both cases, or at minimum adding a comment explaining why this is splice-only.

Comment on lines +9957 to +9992
let mut shared_input_signature_unblocked = false;
{
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_mut() {
if signing_session.awaiting_holder_shared_input_signature() {
let splice_input_index = signing_session
.unsigned_tx()
.shared_input_index()
.expect("Missing shared input index while awaiting a splice signature");
log_trace!(logger, "Attempting to generate pending splice shared input signature...");
if let Ok(shared_input_signature) = self.context.holder_signer.sign_splice_shared_input(
&self.funding.channel_transaction_parameters,
signing_session.unsigned_tx().tx(),
splice_input_index as usize,
&self.context.secp_ctx,
) {
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;
}
}
}
}

let mut tx_signatures = None;
let mut funding_tx = None;
if funding_commit_sig.is_some() || shared_input_signature_unblocked {
if let Some(signing_session) = self.context.interactive_tx_signing_session.as_ref() {
signing_session.holder_tx_signatures().filter(|_| !self.is_awaiting_monitor_update())
if !self.is_awaiting_monitor_update() && !self.context.signer_pending_funding {
tx_signatures = signing_session.holder_tx_signatures();
funding_tx = tx_signatures.as_ref().and_then(|_| signing_session.signed_tx());
}
} else {
debug_assert!(false);
None
}
} else {
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.

Minor: the return value of provide_holder_shared_input_signature (which includes (Option<TxSignatures>, Option<Transaction>)) is discarded at line 9974 and then re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 9986-9987. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3 total calls to holder_tx_signatures() (which clones and rebuilds each time). Not a correctness issue, but you could reuse the values from provide_holder_shared_input_signature to avoid redundant cloning.

let holder_tx_signatures = self.holder_tx_signatures.as_ref()?;
let counterparty_tx_signatures = self.counterparty_tx_signatures.as_ref()?;
let shared_input_signature = self.shared_input_signature.as_ref();
let holder_tx_signatures = self.holder_tx_signatures()?;

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.

Note: signed_tx() previously accessed self.holder_tx_signatures (the field) directly, but now calls self.holder_tx_signatures() (the method). The method adds two filters:

  1. Shared input signature must be present (new in this PR - correct for async signing)
  2. Timing condition: (has_received_commitment_signed && holder_sends_tx_signatures_first) || has_received_tx_signatures()

The timing filter is effectively a no-op here because counterparty_tx_signatures.clone()? on the next line already returns None if counterparty hasn't sent theirs. But it changes signed_tx() from being purely about "are all signatures available" to also encoding protocol ordering constraints.

@ldk-claude-review-bot

ldk-claude-review-bot commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

I've thoroughly reviewed every file and hunk in this PR diff, cross-referencing with the full implementation context and my prior review notes. All previously identified critical issues from earlier review passes have been addressed in the current version:

  • interactivetxs.rs:576awaiting_holder_shared_input_signature() correctly uses .unwrap_or(false)
  • channelmanager.rs — Uses Event::SpliceNegotiated (not the removed SplicePending)
  • channelmanager.rs — The debug_assert for counterparty_initial_commitment_signed_result being None is present at line ~13970

No new issues found.

The async signing flow for splice shared inputs is correct:

  • The two-phase approach (provide_holder_witnesses then provide_holder_shared_input_signature) correctly separates synchronous wallet witnesses from the potentially-async channel signer operation.
  • holder_tx_signatures() correctly filters out incomplete signatures (missing shared input), preventing premature tx_signatures messages.
  • signer_maybe_unblocked correctly handles all state combinations: commitment sig pending + shared input pending, only one pending, neither pending.
  • Guard conditions (signer_pending_funding, is_awaiting_monitor_update) are checked before attempting to send tx_signatures.
  • on_tx_signatures_exchange is called from signer_maybe_unblocked with the correct best_block_height, and all FundingTxSigned fields (splice_negotiated, splice_locked, funding_tx) are fully processed in the signer_unblocked handler.
  • check_free_holding_cells() is called after the per_peer_state lock is explicitly dropped, matching the pattern in other handlers.
  • The monitor update completion path and signer unblocked path are correctly disjoint.

Prior inline comments (already posted, still applicable as minor nits) are listed in the "Your Prior Review Comments" section above.

@codecov

codecovBot commented Apr 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.61039% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (946ee09) to head (f408b17).
⚠️ Report is 7 commits behind head on main.

Files with missing linesPatch %Lines
lightning/src/ln/channel.rs90.90%4 Missing and 5 partials ⚠️
lightning/src/ln/interactivetxs.rs84.90%6 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs91.66%3 Missing and 3 partials ⚠️
lightning/src/util/test_channel_signer.rs80.00%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #4579 +/- ##
==========================================
+ Coverage 86.11% 86.15% +0.04% 
==========================================
Files 157 157 Lines 108841 109096 +255 Branches 108841 109096 +255 ==========================================
+ Hits 93725 93989 +264 + Misses 12497 12485 -12 - Partials 2619 2622 +3 
FlagCoverage Δ
tests86.15% <89.61%> (+0.04%)⬆️

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.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

Comment threadlightning/src/ln/channel.rs Outdated
@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Shouldn't most callers of this now be checking if we have the shared input as well?

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.

I already accounted for this. All callers of has_holder_tx_signatures now only care about whether the user approved the transaction by signing, not necessarily if it's fully signed (with the shared input signature).

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.

Hmm.the log in channel_reestablish probably needs updating, maybe also the not_broadcasted_initial_funding in shutdown and is_funding_broadcastable, (though those are for V2, so I guess it doesn't matter). Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

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.

ISTM this method probably needs renaming, though, cause it currently implies we have all the holder's tx signatures, but we don't.

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.

the log in channel_reestablish probably needs updating

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Also the handling in commitment_signed isn't clear to me at all - that is load-bearing in a few places, do we handle it correctly if we suddenly allow state to move forward a few steps but are still somehow waiting to send our last sig?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

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.

This seems fine? If we don't have the holder tx_signatures and the user hasn't provided their witnesses yet, then InteractiveTxSigningSession::holder_tx_signatures should be None and we log we're waiting for the user's signatures.

Presumably we should also log if we're waiting on the signer to provide the shared input?

I don't quite follow (maybe the name change helps with understanding), processing the counterparty's commitment_signed is something that needs to happen prior to us releasing our tx_signatures, which we do within the same lock in ChannelManager::funding_transaction_signed regardless of whether the shared input signature is async or not.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

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.

Still not clear here.

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.

Right, but in the !has_holder_tx_signatures case we stash the message for processing later. Does that need updating, and if not why not?

It doesn't because this only cares about whether the user signed or not, as that is the context (funding_transaction_signed) where we re-process the stashed commitment_signed. Previously it was checking holder_tx_signatures as that was the only check we had since we assumed the signer would always respond with the shared input signature immediately, resulting in the complete tx_signatures. Now that it doesn't, we have has_holder_witnesses, which corresponds exactly to "whether the user signed or not".

@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 9df5f83 to fc1921cCompareMay 6, 2026 23:42
Comment threadlightning/src/ln/channelmanager.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from fc1921c to 436c5fbCompareMay 7, 2026 16:48
@wpaulino
wpaulino requested review from TheBlueMatt and jkczyzMay 7, 2026 16:48
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/channelmanager.rs Outdated
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch 2 times, most recently from 6cc0b8c to 25cd9e0CompareMay 7, 2026 22:18
Comment threadlightning/src/ln/channelmanager.rs
Comment threadlightning/src/ln/async_signer_tests.rs
Comment threadlightning/src/ln/interactivetxs.rs
Comment threadlightning/src/ln/interactivetxs.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 25cd9e0 to 513e8cfCompareMay 7, 2026 22:34
shared_input_signature_unblocked = true;
signing_session
.provide_holder_shared_input_signature(shared_input_signature)
.map_err(ChannelError::close)?;

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: The return value of provide_holder_shared_input_signature (which is Result<(Option<TxSignatures>, Option<Transaction>), String>) is discarded here, then immediately re-fetched via signing_session.holder_tx_signatures() and signing_session.signed_tx() at lines 10044-10045. Each call to signed_tx() internally calls holder_tx_signatures() again, resulting in 3-4 total calls to holder_tx_signatures() (which clones every time) and 2 calls to finalize(). Consider reusing the return value:

let(unblocked_tx_signatures, unblocked_funding_tx) = signing_session
.provide_holder_shared_input_signature(shared_input_signature).map_err(ChannelError::close)?;

Then use these values directly instead of re-fetching.

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.

This doesn't make sense, if we have a shared input then we'd never have a finalized transaction in the earlier call.

Comment threadlightning/src/ln/channelmanager.rs Outdated
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 513e8cf to d794030CompareMay 7, 2026 23:05
@wpaulino
wpaulino requested a review from jkczyzMay 8, 2026 17:37
jkczyz
jkczyz previously approved these changes May 8, 2026
Comment threadlightning/src/ln/interactivetxs.rs Outdated
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_tx_witnesses(&self) -> bool {

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.

Consider naming has_holder_witnesses to match provide_holder_witnesses.

@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.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd 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.

While user signatures may be provided whenever ready at the user's
discretion when handling a `FundingTransactionReadyForSigning` event, it
does not cover the user's signature for the 2-of-2 multisig input in a
splice. This signature is obtained via the `EcdsaChannelSigner`, which
did not support providing it asynchronously.
Since the splice shared input signature is part of the `tx_signatures`
message, we're not allowed to send the message until it's complete. This
results in us needing to explicitly handle the signature exchange logic
when the signer unblocks the shared input signature.
@wpaulino
wpaulinoforce-pushed the async-sign-shared-input branch from 365099d to f408b17CompareMay 12, 2026 17:36
}

pub fn has_holder_tx_signatures(&self) -> bool {
pub fn has_holder_witnesses(&self) -> bool {

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.

Should we clarify that this is not all the witnesses somehow? Maybe has_holder_nonshared_sigs or has_holder_nonshared_witnesses?

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.

Well that should already be implied by "holder", no? "Holder witnesses" can only be those which the holder can single-handedly craft.

@@ -612,9 +613,21 @@ impl InteractiveTxSigningSession {
self.holder_tx_signatures.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.

Still not clear here.

@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.

Only change since @jkczyz's review is a rename he requested, so landing.

@TheBlueMatt
TheBlueMatt merged commit 265e976 into lightningdevkit:mainMay 13, 2026
23 of 24 checks passed
@wpaulino
wpaulino deleted the async-sign-shared-input branch May 13, 2026 21:13
@jkczyzjkczyz mentioned this pull request Jun 15, 2026
50 tasks
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.

sign_splice_shared_input doesn't support async

5 participants

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