Skip to content

Include failure context in splice events#4514

Open
jkczyz wants to merge 9 commits intolightningdevkit:mainfrom
jkczyz:2026-03-splice-rbf-fail-event
Open

Include failure context in splice events#4514
jkczyz wants to merge 9 commits intolightningdevkit:mainfrom
jkczyz:2026-03-splice-rbf-fail-event

Conversation

@jkczyz
Copy link
Copy Markdown
Contributor

@jkczyz jkczyz commented Mar 26, 2026

When a splice negotiation round fails, the user needs to know why it failed and what they can do about it. Previously, SpliceFailed gave no indication of the failure cause and didn't return the contribution, making it difficult to retry.

This PR adds failure context to the splice events so users can make informed retry decisions:

  • NegotiationFailureReason indicates what went wrong — peer disconnect, counterparty abort, invalid contribution, insufficient feerate, channel closing, etc. Each variant documents how to resolve it.
  • The FundingContribution from the failed round is returned in the event. Users can feed it back to funding_contributed to retry as-is, or inspect it to understand which inputs, outputs, and feerate were used when constructing a new contribution.
  • SplicePending and SpliceFailed are renamed to SpliceNegotiated and SpliceNegotiationFailed to reflect that each negotiation round (initial or RBF) independently resolves to one of these two outcomes.
  • DiscardFunding is now emitted before SpliceNegotiationFailed so wallet inputs are unlocked before the failure handler runs. Otherwise, a retry during SpliceNegotiationFailed handling would leave inputs locked, and the subsequent DiscardFunding would incorrectly unlock inputs committed to the new attempt.

Additionally, SpliceFundingFailed internals are simplified:

  • Dead funding_txo and channel_type fields are removed.
  • An unreachable was_negotiated check is removed from the contribution pop.
  • Inputs and outputs are derived from FundingContribution via a unified splice_funding_failed_for! macro, replacing the old approach of extracting them from FundingNegotiation variants. Unused conversion methods are removed.

Also fixes a bug in into_unique_contributions where outputs were compared by full TxOut equality instead of script_pubkey. This could fail to filter change outputs that reused the same address but had different values across RBF rounds.

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Mar 26, 2026

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

@jkczyz jkczyz self-assigned this Mar 26, 2026
@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch 2 times, most recently from 333980d to c2cad50 Compare March 28, 2026 00:08
@jkczyz jkczyz requested review from TheBlueMatt and wpaulino March 28, 2026 00:10
/// sent an invalid message). Retry by calling [`ChannelManager::splice_channel`].
///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
NegotiationError,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note we could have this wrap AbortReason -- we had an internal NegotiationError struct that already wrapped this along with contributions -- but it would require making AbortReason public. Not sure if we want to expose all its variants.

fn with_negotiation_failure_reason(mut self, reason: NegotiationFailureReason) -> Self {
match self {
QuiescentError::FailSplice(_, ref mut r) => *r = reason,
_ => debug_assert!(false, "Expected FailSplice variant"),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Little unfortunate we need to do this, but I couldn't find a better way other than passing NegotiationFailureReason to abandon_quiescent_action and quiescent_action_into_error, which doesn't seem right since they could be for future QuiescentAction variants.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Does feel like quiescent_action_into_error could take the NegotiationFailureReason and we could also use NegotiationFailureReason for dual-funding (the DiscardFunding variant) but this seems fine.

Comment on lines -1646 to -1649
/// The outpoint of the channel's splice funding transaction, if one was created.
abandoned_funding_txo: Option<OutPoint>,
/// The features that this channel will operate with, if available.
channel_type: Option<ChannelTypeFeatures>,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Didn't feel like there was much value to these.

  • abandoned_funding_txo would be set only if there's a failure after the transaction was negotiated but before signed.
  • In the future, when channel_type can change, maybe it is just part of FundingContribution? Could also keep it if you prefer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt @wpaulino! 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.

1 similar comment
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt @wpaulino! 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 @wpaulino! 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.

1 similar comment
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd Reminder

Hey @TheBlueMatt @wpaulino! 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.

@jkczyz jkczyz marked this pull request as ready for review April 2, 2026 17:04
Comment on lines +3185 to 3193
impl QuiescentError {
fn with_negotiation_failure_reason(mut self, reason: NegotiationFailureReason) -> Self {
match self {
QuiescentError::FailSplice(_, ref mut r) => *r = reason,
_ => debug_assert!(false, "Expected FailSplice variant"),
}
self
}
}
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 debug_assert!(false) path is only reachable in non-production builds if a caller passes a non-FailSplice variant. Currently the only caller passes through quiescent_action_into_error which always produces FailSplice for production code (only DoNothing in test builds). But debug_assert!(false) will silently return self unchanged in release builds — consider using debug_assert! with a more specific message about which variant was unexpected, or restructuring so the method only accepts FailSplice to make this impossible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +6943 to +6947
let is_initiator = pending_splice
.funding_negotiation
.take()
.map(|negotiation| negotiation.is_initiator())
.unwrap_or(false);
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.

The is_initiator fallback when funding_negotiation is None defaults to false, which means splice_funding_failed_for! will return None for non-initiators (since the macro has None if !$is_initiator => None). This means if the funding negotiation was already taken before reset_pending_splice_state is called and the node was actually the initiator, the splice failure event would be silently dropped.

Is funding_negotiation ever None when reset_pending_splice_state is called? If so, defaulting to false may suppress failure events for the initiator. If not, this should be a debug_assert or .expect() to catch violations of that invariant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added debug assertions.

Comment on lines +6989 to +6993
let is_initiator = pending_splice
.funding_negotiation
.as_ref()
.map(|negotiation| negotiation.is_initiator())
.unwrap_or(false);
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.

Same concern here: funding_negotiation.as_ref() being None causes is_initiator to default to false, which will cause the splice_funding_failed_for! macro to return None for non-initiators. Since maybe_splice_funding_failed is the read-only version used during serialization, silently returning None means the splice failure events won't be persisted and the user won't learn about the failure after restart.

Consider whether funding_negotiation can actually be None in this path, and if not, using expect to enforce the invariant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added debug assertions.

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

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

I've completed a thorough re-review of the entire PR diff. All previously flagged issues remain applicable and no new significant issues were found.

Review Summary

Previously flagged issues (still applicable):

  1. lightning/src/ln/channel.rs:3188with_negotiation_failure_reason silently no-ops in release for non-FailSplice variants.
  2. lightning/src/ln/channel.rs:7267is_initiator defaults to false when funding_negotiation is None, potentially suppressing initiator failure events.
  3. lightning/src/ln/channel.rs:7317 — Same is_initiator defaulting issue in maybe_splice_funding_failed (serialization path).
  4. lightning/src/ln/channel.rs:14025 — Duplicate quiescent action returns NegotiationFailureReason::Unknown because .with_negotiation_failure_reason() is not chained.
  5. lightning/src/ln/channelmanager.rs:11869 — unnecessary .clone() on contribution.

Cross-cutting concern (still applicable):

  • Forward compatibility of NegotiationFailureReason: All 8 variant IDs are even (0, 2, 4, ..., 14). In impl_writeable_tlv_based_enum_upgradable!, unknown even variants return Err(UnknownRequiredFeature). Since the reason field (TLV type 11, odd) uses upgradable_option in the reader, the MaybeReadable impl is invoked — but an unknown even variant ID within NegotiationFailureReason still produces an Err that propagates via ?, failing the entire event deserialization. This prevents ChannelManager loading when a newer writer adds a new even variant. Future variants should use odd IDs for graceful degradation to Unknown.

Prior comment #5 (DiscardFunding guard inconsistency) is now resolved: All emission sites consistently use SpliceFundingFailed::into_parts() which returns Option<FundingInfo>, and all callers check if let Some(funding_info) before emitting DiscardFunding. The guard is centralized in into_parts() and applied uniformly.

No new issues found beyond those previously reported.

@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch from c2cad50 to c469423 Compare April 3, 2026 01:25
@jkczyz
Copy link
Copy Markdown
Contributor Author

jkczyz commented Apr 3, 2026

Rebased

@@ -4128,6 +4118,16 @@ impl<
},
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: Unlike handle_quiescent_error (line ~6619), this site unconditionally emits DiscardFunding even when contributed_inputs and contributed_outputs are both empty (which happens for the initiator when all inputs are committed to prior rounds). The same inconsistency exists at lines ~4417, ~4920, ~11853, ~12012, ~12180, ~12329, ~15448, and ~18122.

Consider adding the same guard here:

if !splice_funding_failed.contributed_inputs.is_empty() || !splice_funding_failed.contributed_outputs.is_empty() {
    // emit DiscardFunding
}

Or, equivalently, have the macro return None for the initiator when there are no unique contributions (matching the non-initiator behavior) and emit SpliceNegotiationFailed without DiscardFunding in those cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed by making SpliceFundingFailed consumable into Option-al parts rather than exposing fields. The into_parts method now checks for empty contributions.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 3, 2026

Codecov Report

❌ Patch coverage is 84.69945% with 28 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.10%. Comparing base (2adb690) to head (c469423).

Files with missing lines Patch % Lines
lightning/src/ln/channelmanager.rs 79.61% 21 Missing ⚠️
lightning/src/ln/funding.rs 40.00% 3 Missing ⚠️
lightning/src/ln/channel.rs 96.55% 1 Missing and 1 partial ⚠️
lightning/src/events/mod.rs 85.71% 1 Missing ⚠️
lightning/src/ln/functional_test_utils.rs 90.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4514   +/-   ##
=======================================
  Coverage   87.09%   87.10%           
=======================================
  Files         163      163           
  Lines      108856   108776   -80     
  Branches   108856   108776   -80     
=======================================
- Hits        94808    94744   -64     
+ Misses      11563    11545   -18     
- Partials     2485     2487    +2     
Flag Coverage Δ
fuzzing 40.25% <30.63%> (+0.05%) ⬆️
tests 86.21% <84.69%> (+<0.01%) ⬆️

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

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

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

@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch 2 times, most recently from 4f6b1a6 to 0609565 Compare April 3, 2026 15:33
@jkczyz
Copy link
Copy Markdown
Contributor Author

jkczyz commented Apr 3, 2026

  • Forward compatibility of NegotiationFailureReason: Uses impl_writeable_tlv_based_enum! with all-even variant IDs. A future variant added by a newer LDK version would cause UnknownRequiredFeature on deserialization, blocking ChannelManager loading on older versions. Consider impl_writeable_tlv_based_enum_upgradable! or documenting the constraint.

Changed to use impl_writeable_tlv_based_enum_upgradable and changed the reason from default_value to upgradable_option, defaulting to NegotiationFailureReason::Unknown.

@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch from 0609565 to 44ecc06 Compare April 3, 2026 15:39
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 3rd Reminder

Hey @TheBlueMatt @wpaulino! 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.

1 similar comment
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 3rd Reminder

Hey @TheBlueMatt @wpaulino! 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

🔔 4th Reminder

Hey @TheBlueMatt @wpaulino! 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

🔔 4th Reminder

Hey @TheBlueMatt @wpaulino! 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.

/// [`ChannelManager::splice_channel`], or wait for the counterparty to initiate.
///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
CounterpartyAborted,
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.

Is the "Message data" in tx_abort not a string that we can include here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
PeerDisconnected,
/// The counterparty aborted the negotiation by sending `tx_abort`. Retry by calling
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.

Almost each docstring says "Retry by calling splice_channel". That doesn't seem like particularly helpful advice especially in cases where the counterparty rejected implying just trying again is unlikely to fix it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here it really depends on the reason. They may just not like our fee rate, for instance. Protocol violations result in a disconnect rather than an abort. Hmm... that makes me wonder if retrying on disconnect is always the right approach... Presumably protocol violations are an LDK bug.

/// Adjust the contribution and retry via [`ChannelManager::splice_channel`].
///
/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
ContributionInvalid,
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.

It does seem like some of these could use more details (error message string?). As-is given the recommendation for ~all enum variants is "retry by calling splice_channel again" its not really clear to me why we should bother with an enum - an enum is useful if the downstream code has different handling for different cases, but here that doesn't really seem likely/possible. If we can't expose more details that are useful for automated action, we could probably do with an enum with three variants and a developer-readable string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, the docs could be improved here. Maybe re-calling splice_channel isn't always necessary? In some cases, calling funding_contributed at a later time may be sufficient. We are providing the FundingContribution after all.

So it does seem like different actions are required depending on the variant.

  • PeerDisconnected: retry upon re-connection
  • ChannelNotReady: retry when channel is in a usable state (assuming it's not closing)
  • FeeRateTooLow: retry with a higher fee rate
  • ContributionInvalid: retry with a re-computed contribution
  • NegotiationError: probably not retryable as it indicates a counterparty error
  • CounterpartyAborted: might be able to retry but would need to examine the tx_abort message (e.g., counterparty doesn't like your fee rate).

We could have a top-level Retryable that holds one of these instead, if you prefer? We could go as far as only putting the FundingContribution in applicable sub-variants. Though a user may want to see it for other variants.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I assume ChannelNotReady is really "you can't retry" (why were you splicing an unopened channel anyway? but probably its closing), FeeRateTooLow and ContributionInvalid are really "retry now with a fresh splice_channel call as that'll tell you what the new required feerate is to do a fresh rbf from scracth or will fix your contribution, hopefully". So it does seem like its basically "retry with a fresh splice_channel call, or not", with the one maybe-exception of PeerDisconnected? We could leave the enum and include a function that returns "retryable or not", but I'm not really sure why we shouldn't just return a string+bool pair (in a struct) - do we anticipate downstream code will have any different behavior based on anything beyond "should I retry this" or is it just gonna log/print an error?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Probably not other than when / how should you retry in case of the PeerDisconnected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I suppose given we don't know when the peer will reconnect, we might as well just process it the same and call splice_channel again upon re-connection. Otherwise, we'd need to make sure not to process the associated DiscardFunding which would mean locking up the UTXOs indefinitely. Instead, the client can simply always queue up a "retry when connected".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Discussed offline. Updated to have an is_retryable method that the counterparty calls rather than examining the variant. Would prevent allocating strings in many cases.

if let Some(splice_funding_failed) = splice_funding_failed {
let (funding_info, contribution) = splice_funding_failed.into_parts();
let mut pending_events = self.pending_events.lock().unwrap();
if let Some(funding_info) = funding_info {
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.

We should document which comes first and maybe test it more exhaustively - eg in get_and_clear_pending_events assert the order if both events appear for the same channel?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Missed this about documentation. We do assert the order in expect_splice_failed_events. Is that what you were looking for?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, actually the docs do say "preceding" event.

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.

Ah, yea, I mean I was thinking of shoving it in channelmanager.rs so its fully exhaustive. In splice handling specifically is okay but 50/50 claude would run into an issue and switch to manually matching events cause they're in the wrong order even though its a bug :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, right. Problem is it's hard to robustly test. A user could call funding_contributed back-to-back with bad input and trigger a failed assertion. Maybe we don't care if a debug assertion? Or maybe we configure it only for tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ok, so the fact that get_and_clear_pending_events is already test-only escaped me. Added now. Though, in production, will async event handling be a problem?

@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch from 44ecc06 to 48f950f Compare April 7, 2026 04:22
Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

Yea, I think the API lgtm now

jkczyz and others added 2 commits April 7, 2026 11:50
Each splice negotiation round can fail for different reasons, but
Event::SpliceFailed previously gave no indication of what went wrong.
Add a NegotiationFailureReason enum so users can distinguish failures
and take appropriate action (e.g., retry with a higher feerate vs.
wait for the channel to become usable).

The reason is determined at each channelmanager emission site based on
context rather than threaded through channel.rs internals, since the
channelmanager knows the triggering context (disconnect, tx_abort,
shutdown, etc.) while channel.rs functions like abandon_quiescent_action
handle both splice and non-splice quiescent actions.

The one exception is QuiescentError::FailSplice, which carries a reason
alongside the SpliceFundingFailed. This is appropriate because FailSplice
is already splice-specific, and the channel.rs code that constructs it
(e.g., contribution validation, feerate checks) knows the specific
failure cause. A with_negotiation_failure_reason method on QuiescentError
allows callers to override the default when needed.

Older serializations that lack the reason field default to Unknown via
default_value in deserialization. The persistence reload path uses
PeerDisconnected since a reload implies the peer connection was lost.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the abandoned_funding_txo and channel_type fields on
Event::SpliceFailed with an Option<FundingContribution> from the failed
round. Users can feed this back to funding_contributed to retry or use
it to inform a fresh attempt via splice_channel.

Also makes FundingContribution::feerate() public so users can inspect
the feerate when deciding whether to retry or bump.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch from 48f950f to 4c64e31 Compare April 7, 2026 16:54
@jkczyz
Copy link
Copy Markdown
Contributor Author

jkczyz commented Apr 7, 2026

Regarding the earlier fuzz failure in CI, it appears that #4536 fixes it.

@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch from 4c64e31 to 0287ceb Compare April 7, 2026 20:01
@jkczyz jkczyz requested a review from TheBlueMatt April 7, 2026 20:01
@jkczyz
Copy link
Copy Markdown
Contributor Author

jkczyz commented Apr 7, 2026

Latest push looks identical but fixes a rebase screw-up causing tests to not pass on each commit.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 5th Reminder

Hey @TheBlueMatt @wpaulino! 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.

jkczyz and others added 7 commits April 8, 2026 14:31
Reverse the event ordering at all emission sites so that
Event::DiscardFunding is emitted before Event::SpliceFailed. If the
user retries the splice when handling SpliceFailed, the contributed
inputs would still be locked. A subsequent DiscardFunding would then
incorrectly unlock inputs that are now committed to the new attempt.
Emitting DiscardFunding first avoids this by ensuring inputs are
unlocked before any retry occurs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename Event::SplicePending to Event::SpliceNegotiated and
Event::SpliceFailed to Event::SpliceNegotiationFailed. These names
better reflect the per-round semantics: each negotiation attempt
resolves to one of these two outcomes, independent of the overall
splice lifecycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The was_negotiated check is unnecessary because reset_pending_splice_state
only runs when funding_negotiation is present, meaning
on_tx_signatures_exchange hasn't been called yet. Since the feerate is
only recorded in last_funding_feerate_sat_per_1000_weight during
on_tx_signatures_exchange, the current round's feerate can never match
it. So the contribution can always be unconditionally popped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Filter outputs by script_pubkey rather than full TxOut equality. Outputs
reusing the same address as a prior round are still considered committed
even if the value differs (e.g., different change amounts across RBF
rounds with different feerates).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the maybe_create_splice_funding_failed! macro and
splice_funding_failed_for method with a unified splice_funding_failed_for!
macro that derives contributed inputs and outputs from the
FundingContribution rather than extracting them from the negotiation
state.

Callers pass ident parameters for which PendingSplice filtering methods
to use: contributed_inputs/contributed_outputs when the current round's
contribution has been popped or was never pushed, and
prior_contributed_inputs/prior_contributed_outputs for the read-only
persistence path where the contribution is cloned instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…hods

Now that splice_funding_failed_for! derives inputs and outputs from
FundingContribution directly, remove the unused NegotiationError struct
and into_negotiation_error methods from the interactive tx types, along
with the into/to_contributed_inputs_and_outputs methods on
ConstructedTransaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkczyz jkczyz force-pushed the 2026-03-splice-rbf-fail-event branch from 0287ceb to f43c892 Compare April 8, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants