Abort active splice RBF when prior candidate confirms
What changed, and why it matters
This commit fixes a state-handling bug in Lightning Dev Kit's splicing feature. When a user tries to speed up or replace a pending splice (an 'RBF' attempt) and the older splice transaction unexpectedly gets confirmed on-chain, the software could get stuck with two conflicting splice attempts active. The patch detects that situation, safely cancels the newer RBF attempt, tells the peer, returns any locked-up funds, and resumes normal channel operation. If signing has already progressed too far, it keeps the RBF alive until it finishes to avoid unsafe cancellation.
Review and merge if part of supported release branch; ensure downstream users running splicing-enabled nodes update to include this fix. No immediate emergency response is indicated, but the fix prevents potential channel-stuck or fund-lockup scenarios.
Security signals we found
State-conflict handling between confirmed splice candidate and active RBF negotiation
Structured abort propagation through chain-event path
Conditional abort based on holder signature progress to avoid unsafe cancellation
Peer notification via TxAbort and local failure via SpliceFundingFailed
Holding-cell release after splice RBF abort
New regression tests for connected, disconnected, reloaded, and edge cases
Evidence from the diff
The change adds a new SpliceRbfAbort signal propagated from Channel::transactions_confirmed through ChannelManager. When a prior splice candidate confirms, abort_ongoing_rbf_after_splice_confirmation checks whether an RBF negotiation is still active and whether holder funding signatures have already been provided. If safe to abort, it resets the pending splice state, generates a TxAbort with AbortReason::RbfUnavailable, and returns any local funding contribution via SpliceFundingFailed. ChannelManager then sends the TxAbort to the connected peer, fails the local splice contribution with NegotiationFailureReason::CannotInitiateRbf, and releases holding-cell updates. If signing has advanced too far (has_holder_witnesses), the RBF is retained until completion because promotion is blocked on its completion. The patch also updates test utilities and adds several new tests covering connected, disconnected, reloaded, no-local-contribution, and advanced-signing cases.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/functional_test_utils.rslightning/src/ln/splicing_tests.rsInspect captured patch +623 / −42
### lightning/src/ln/channel.rs
@@ -7434,6 +7434,7 @@ type BestBlockUpdatedRes = (
Option<FundingConfirmedMessage>,
Vec<(HTLCSource, PaymentHash)>,
Option<msgs::AnnouncementSignatures>,
+ Option<SpliceRbfAbort>,
);
/// The result of handling a `tx_complete` message during interactive transaction construction.
@@ -7503,6 +7504,12 @@ pub struct SpliceFundingFailed {
contribution: FundingContribution,
}
+/// Information about an active RBF negotiation aborted after a prior splice candidate confirmed.
+pub(super) struct SpliceRbfAbort {
+ pub tx_abort: msgs::TxAbort,
+ pub splice_funding_failed: Option<SpliceFundingFailed>,
+}
+
impl SpliceFundingFailed {
fn from_contribution<'a>(
contribution: FundingContribution,
@@ -7789,6 +7796,50 @@ where
splice_funding_failed
}
+ fn abort_ongoing_rbf_after_splice_confirmation<L: Logger>(
+ &mut self, logger: &L,
+ ) -> Option<SpliceRbfAbort> {
+ let has_ongoing_rbf = self
+ .pending_splice
+ .as_ref()
+ .map(|pending_splice| {
+ pending_splice.has_confirmed_candidate()
+ && pending_splice.funding_negotiation.is_some()
+ })
+ .unwrap_or(false);
+ if !has_ongoing_rbf {
+ return None;
+ }
+
+ // Before the current round reaches AwaitingSignatures, the retained signing session belongs
+ // to the prior candidate and must not prevent aborting the new RBF.
+ let has_provided_funding_signatures = self.has_pending_splice_awaiting_signatures()
+ && self
+ .context
+ .interactive_tx_signing_session
+ .as_ref()
+ .map(|signing_session| signing_session.has_holder_witnesses())
+ .unwrap_or(false);
+ if has_provided_funding_signatures {
+ // Once signing has advanced this far, leave the negotiation active and allow the
+ // signature exchange to continue. If the confirmed candidate reaches lock-in first,
+ // normal promotion will discard the conflicting RBF attempt.
+ debug_assert!(!self.should_reset_pending_splice_state(true));
+ log_debug!(
+ logger,
+ "Continuing an RBF negotiation after another splice candidate confirmed because signing has advanced too far to abort safely",
+ );
+ return None;
+ }
+
+ log_info!(logger, "Aborting an active RBF negotiation after a splice candidate confirmed");
+ let splice_funding_failed = self.reset_pending_splice_state();
+ let tx_abort =
+ AbortReason::RbfUnavailable("A negotiated splice candidate has confirmed".to_owned())
+ .into_tx_abort_msg(self.context.channel_id());
+ Some(SpliceRbfAbort { tx_abort, splice_funding_failed })
+ }
+
pub(super) fn maybe_splice_funding_failed(&self) -> Option<SpliceFundingFailed> {
if !self.should_reset_pending_splice_state(true) {
return None;
@@ -12463,7 +12514,8 @@ where
pub fn transactions_confirmed<NS: NodeSigner, L: Logger>(
&mut self, block_hash: &BlockHash, height: u32, txdata: &TransactionData,
chain_hash: ChainHash, node_signer: &NS, user_config: &UserConfig, logger: &L
- ) -> Result<(Option<FundingConfirmedMessage>, Option<msgs::AnnouncementSignatures>), ClosureReason> {
+ ) -> Result<(Option<FundingConfirmedMessage>, Option<msgs::AnnouncementSignatures>, Option<SpliceRbfAbort>), ClosureReason> {
+ let mut splice_rbf_abort = None;
for &(index_in_block, tx) in txdata.iter() {
let mut confirmed_tx = ConfirmedTransaction::from(tx);
@@ -12486,11 +12538,11 @@ where
if let Some(channel_ready) = self.check_get_channel_ready(height, logger) {
log_info!(logger, "Sending a channel_ready to our peer");
let announcement_sigs = self.get_announcement_sigs(node_signer, chain_hash, user_config, height, logger);
- return Ok((Some(FundingConfirmedMessage::Establishment(channel_ready)), announcement_sigs));
+ return Ok((Some(FundingConfirmedMessage::Establishment(channel_ready)), announcement_sigs, splice_rbf_abort));
}
}
- if let Some(pending_splice) = &mut self.pending_splice {
+ let confirmed_funding_index = if let Some(pending_splice) = &mut self.pending_splice {
let mut confirmed_funding_index = None;
let mut funding_already_confirmed = false;
@@ -12511,7 +12563,14 @@ where
}
}
- if let Some(confirmed_funding_index) = confirmed_funding_index {
+ confirmed_funding_index
+ } else {
+ None
+ };
+
+ if let Some(confirmed_funding_index) = confirmed_funding_index {
+ splice_rbf_abort = self.abort_ongoing_rbf_after_splice_confirmation(logger);
+ if let Some(pending_splice) = &mut self.pending_splice {
if let Some(splice_locked) = pending_splice.check_get_splice_locked(
&self.context,
confirmed_funding_index,
@@ -12548,14 +12607,14 @@ where
monitor_update,
discarded_funding,
splice_funding_failed,
- )), announcement_sigs));
+ )), announcement_sigs, splice_rbf_abort));
}
}
}
}
- Ok((None, None))
+ Ok((None, None, splice_rbf_abort))
}
/// When a new block is connected, we check the height of the block against outbound holding
@@ -12585,7 +12644,7 @@ where
fn do_best_block_updated<NS: NodeSigner, L: Logger>(
&mut self, height: u32, highest_header_time: Option<u32>,
chain_node_signer: Option<(ChainHash, &NS, &UserConfig)>, logger: &L
- ) -> Result<(Option<FundingConfirmedMessage>, Vec<(HTLCSource, PaymentHash)>, Option<msgs::AnnouncementSignatures>), ClosureReason> {
+ ) -> Result<BestBlockUpdatedRes, ClosureReason> {
let mut timed_out_htlcs = Vec::new();
// This mirrors the check in ChannelManager::decode_update_add_htlc_onion, refusing to
// forward an HTLC when our counterparty should almost certainly just fail it for expiring
@@ -12622,7 +12681,7 @@ where
self.get_announcement_sigs(node_signer, chain_hash, user_config, height, logger)
} else { None };
log_info!(logger, "Sending a channel_ready to our peer");
- return Ok((Some(FundingConfirmedMessage::Establishment(channel_ready)), timed_out_htlcs, announcement_sigs));
+ return Ok((Some(FundingConfirmedMessage::Establishment(channel_ready)), timed_out_htlcs, announcement_sigs, None));
}
if matches!(self.context.channel_state, ChannelState::ChannelReady(_)) ||
@@ -12739,15 +12798,15 @@ where
monitor_update,
discarded_funding,
splice_funding_failed,
- )), timed_out_htlcs, announcement_sigs));
+ )), timed_out_htlcs, announcement_sigs, None));
}
}
}
let announcement_sigs = if let Some((chain_hash, node_signer, user_config)) = chain_node_signer {
self.get_announcement_sigs(node_signer, chain_hash, user_config, height, logger)
} else { None };
- Ok((None, timed_out_htlcs, announcement_sigs))
+ Ok((None, timed_out_htlcs, announcement_sigs, None))
}
pub fn get_relevant_txids(&self) -> impl Iterator<Item = (Txid, u32, Option<BlockHash>)> + '_ {
@@ -12791,10 +12850,11 @@ where
let signer_config = None::<(ChainHash, &&dyn NodeSigner, &UserConfig)>;
match self.do_best_block_updated(reorg_height, None, signer_config, logger) {
- Ok((channel_ready, timed_out_htlcs, announcement_sigs)) => {
+ Ok((channel_ready, timed_out_htlcs, announcement_sigs, splice_rbf_abort)) => {
assert!(channel_ready.is_none(), "We can't generate a funding with 0 confirmations?");
assert!(timed_out_htlcs.is_empty(), "We can't have accepted HTLCs with a timeout before our funding confirmation?");
assert!(announcement_sigs.is_none(), "We can't generate an announcement_sigs with 0 confirmations?");
+ assert!(splice_rbf_abort.is_none(), "We can't abort an RBF while unconfirming funding?");
Ok(())
},
Err(e) => Err(e),
### lightning/src/ln/channelmanager.rs
@@ -62,7 +62,8 @@ use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult,
FundedChannel, FundingTxSigned, InboundV1Channel, InteractiveTxMsgError, OutboundHop,
OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult, SpliceFundingFailed,
- SpliceFundingPromotion, StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
+ SpliceFundingPromotion, SpliceRbfAbort, StfuResponse, UpdateFulfillCommitFetch,
+ WithChannelContext,
};
use crate::ln::channel_state::{ChannelDetails, InboundHTLCReference, OutboundHTLCSource};
use crate::ln::funding::{FundingContribution, FundingTemplate};
@@ -16735,7 +16736,7 @@ impl<
PersistenceNotifierGuard::optionally_notify_skipping_background_events(
self, || -> NotifyOption { NotifyOption::DoPersist });
self.do_chain_event(Some(height), |channel| channel.transactions_confirmed(&block_hash, height, txdata, self.chain_hash, &self.node_signer, &self.config.read().unwrap(), &&WithChannelContext::from(&self.logger, &channel.context, None))
- .map(|(a, b)| (a, Vec::new(), b)));
+ .map(|(a, b, c)| (a, Vec::new(), b, c)));
let last_best_block_height = self.best_block.read().unwrap().height;
if height < last_best_block_height {
@@ -16863,7 +16864,7 @@ impl<
);
self.do_chain_event(None, |channel| {
let logger = WithChannelContext::from(&self.logger, &channel.context, None);
- channel.transaction_unconfirmed(txid, &&logger).map(|()| (None, Vec::new(), None))
+ channel.transaction_unconfirmed(txid, &&logger).map(|()| (None, Vec::new(), None, None))
});
}
}
@@ -16902,6 +16903,7 @@ impl<
Option<FundingConfirmedMessage>,
Vec<(HTLCSource, PaymentHash)>,
Option<msgs::AnnouncementSignatures>,
+ Option<SpliceRbfAbort>,
),
ClosureReason,
>,
@@ -16915,6 +16917,7 @@ impl<
let mut failed_channels: Vec<(Result<Infallible, _>, _)> = Vec::new();
let mut timed_out_htlcs = Vec::new();
let mut to_process_monitor_update_actions = Vec::new();
+ let mut needs_holding_cell_release = false;
{
let per_peer_state = self.per_peer_state.read().unwrap();
for (counterparty_node_id, peer_state_mutex) in per_peer_state.iter() {
@@ -16928,14 +16931,36 @@ impl<
None => true,
Some(funded_channel) => {
let res = f(funded_channel);
- if let Ok((funding_confirmed_opt, mut timed_out_pending_htlcs, announcement_sigs)) = res {
+ if let Ok((funding_confirmed_opt, mut timed_out_pending_htlcs, announcement_sigs, splice_rbf_abort)) = res {
for (source, payment_hash) in timed_out_pending_htlcs.drain(..) {
let reason = LocalHTLCFailureReason::CLTVExpiryTooSoon;
let data = self.get_htlc_inbound_temp_fail_data(reason);
let failure_type = source.failure_type(funded_channel.context.get_counterparty_node_id(), *channel_id);
timed_out_htlcs.push((source, payment_hash, HTLCFailReason::reason(reason, data), failure_type));
}
let logger = WithChannelContext::from(&self.logger, &funded_channel.context, None);
+ if let Some(SpliceRbfAbort { tx_abort, splice_funding_failed }) = splice_rbf_abort {
+ let counterparty_node_id = funded_channel.context.get_counterparty_node_id();
+ let channel_id = funded_channel.context.channel_id();
+ if peer_state.is_connected {
+ pending_msg_events.push(MessageSendEvent::SendTxAbort {
+ node_id: counterparty_node_id,
+ msg: tx_abort,
+ });
+ }
+ if let Some(splice_funding_failed) = splice_funding_failed {
+ self.handle_quiescent_error(
+ channel_id,
+ counterparty_node_id,
+ funded_channel.context.get_user_id(),
+ QuiescentError::FailSplice(
+ splice_funding_failed,
+ events::NegotiationFailureReason::CannotInitiateRbf,
+ ),
+ );
+ }
+ needs_holding_cell_release = true;
+ }
match funding_confirmed_opt {
Some(FundingConfirmedMessage::Establishment(channel_ready)) => {
self.send_channel_ready(pending_msg_events, funded_channel, channel_ready);
@@ -17105,6 +17130,9 @@ impl<
for (counterparty_node_id, channel_id) in to_process_monitor_update_actions {
let _ = self.channel_monitor_updated(&channel_id, None, &counterparty_node_id);
}
+ if needs_holding_cell_release {
+ self.check_free_holding_cells();
+ }
if let Some(height) = height_opt {
// If height is approaching the number of blocks we think it takes us to get our
### lightning/src/ln/functional_test_utils.rs
@@ -5391,6 +5391,7 @@ pub struct ReconnectArgs<'a, 'b, 'c, 'd> {
/// and no monitor update is expected
pub pending_responding_commitment_signed_dup_monitor: (bool, bool),
pub pending_htlc_adds: (usize, usize),
+ pub pending_cell_htlc_adds: (usize, usize),
pub pending_htlc_claims: (usize, usize),
pub pending_htlc_fails: (usize, usize),
pub pending_cell_htlc_claims: (usize, usize),
@@ -5416,6 +5417,7 @@ impl<'a, 'b, 'c, 'd> ReconnectArgs<'a, 'b, 'c, 'd> {
pending_responding_commitment_signed: (false, false),
pending_responding_commitment_signed_dup_monitor: (false, false),
pending_htlc_adds: (0, 0),
+ pending_cell_htlc_adds: (0, 0),
pending_htlc_claims: (0, 0),
pending_htlc_fails: (0, 0),
pending_cell_htlc_claims: (0, 0),
@@ -5426,8 +5428,6 @@ impl<'a, 'b, 'c, 'd> ReconnectArgs<'a, 'b, 'c, 'd> {
}
}
-/// pending_htlc_adds includes both the holding cell and in-flight update_add_htlcs, whereas
-/// for claims/fails they are separated out.
pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
let ReconnectArgs {
node_a,
@@ -5440,6 +5440,7 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
send_tx_abort,
expect_renegotiated_funding_locked_monitor_update,
pending_htlc_adds,
+ pending_cell_htlc_adds,
pending_htlc_claims,
pending_htlc_fails,
pending_cell_htlc_claims,
@@ -5491,7 +5492,8 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
resp_1.push(handle_chan_reestablish_msgs!(node_b, node_a));
}
- if pending_cell_htlc_claims.0 != 0
+ if pending_cell_htlc_adds.0 != 0
+ || pending_cell_htlc_claims.0 != 0
|| pending_cell_htlc_fails.0 != 0
|| expect_renegotiated_funding_locked_monitor_update.1
{
@@ -5505,7 +5507,8 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
node_a.node.handle_channel_reestablish(node_b_id, &msg);
resp_2.push(handle_chan_reestablish_msgs!(node_a, node_b));
}
- if pending_cell_htlc_claims.1 != 0
+ if pending_cell_htlc_adds.1 != 0
+ || pending_cell_htlc_claims.1 != 0
|| pending_cell_htlc_fails.1 != 0
|| expect_renegotiated_funding_locked_monitor_update.0
{
@@ -5517,24 +5520,28 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
// We don't yet support both needing updates, as that would require a different commitment dance:
assert!(
(pending_htlc_adds.0 == 0
+ && pending_cell_htlc_adds.0 == 0
&& pending_htlc_claims.0 == 0
&& pending_htlc_fails.0 == 0
&& pending_cell_htlc_claims.0 == 0
&& pending_cell_htlc_fails.0 == 0)
|| (pending_htlc_adds.1 == 0
+ && pending_cell_htlc_adds.1 == 0
&& pending_htlc_claims.1 == 0
&& pending_htlc_fails.1 == 0
&& pending_cell_htlc_claims.1 == 0
&& pending_cell_htlc_fails.1 == 0)
);
let pending_commitment_update = (
pending_htlc_adds.0 != 0
+ || pending_cell_htlc_adds.0 != 0
|| pending_htlc_claims.0 != 0
|| pending_htlc_fails.0 != 0
|| pending_cell_htlc_claims.0 != 0
|| pending_cell_htlc_fails.0 != 0
|| pending_responding_commitment_signed.0,
pending_htlc_adds.1 != 0
+ || pending_cell_htlc_adds.1 != 0
|| pending_htlc_claims.1 != 0
|| pending_htlc_fails.1 != 0
|| pending_cell_htlc_claims.1 != 0
@@ -5590,6 +5597,8 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
if send_tx_abort.0 {
let tx_abort = chan_msgs.7.take().unwrap();
node_a.node.handle_tx_abort(node_b_id, &tx_abort);
+ let tx_abort_ack = get_event_msg!(node_a, MessageSendEvent::SendTxAbort, node_b_id);
+ node_b.node.handle_tx_abort(node_a_id, &tx_abort_ack);
} else {
assert!(chan_msgs.7.is_none());
}
@@ -5609,7 +5618,10 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
}
if pending_commitment_update.0 {
let commitment_update = chan_msgs.2.unwrap();
- assert_eq!(commitment_update.update_add_htlcs.len(), pending_htlc_adds.0);
+ assert_eq!(
+ commitment_update.update_add_htlcs.len(),
+ pending_htlc_adds.0 + pending_cell_htlc_adds.0
+ );
assert_eq!(
commitment_update.update_fulfill_htlcs.len(),
pending_htlc_claims.0 + pending_cell_htlc_claims.0
@@ -5703,6 +5715,8 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
if send_tx_abort.1 {
let tx_abort = chan_msgs.7.take().unwrap();
node_b.node.handle_tx_abort(node_a_id, &tx_abort);
+ let tx_abort_ack = get_event_msg!(node_b, MessageSendEvent::SendTxAbort, node_a_id);
+ node_a.node.handle_tx_abort(node_b_id, &tx_abort_ack);
} else {
assert!(chan_msgs.7.is_none());
}
@@ -5722,7 +5736,10 @@ pub fn reconnect_nodes<'a, 'b, 'c, 'd>(args: ReconnectArgs<'a, 'b, 'c, 'd>) {
}
if pending_commitment_update.1 {
let commitment_update = chan_msgs.2.unwrap();
- assert_eq!(commitment_update.update_add_htlcs.len(), pending_htlc_adds.1);
+ assert_eq!(
+ commitment_update.update_add_htlcs.len(),
+ pending_htlc_adds.1 + pending_cell_htlc_adds.1
+ );
assert_eq!(
commitment_update.update_fulfill_htlcs.len(),
pending_htlc_claims.1 + pending_cell_htlc_claims.1
### lightning/src/ln/splicing_tests.rs
@@ -1233,8 +1233,6 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) {
reconnect_args.send_tx_abort = (true, false);
reconnect_nodes(reconnect_args);
- let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
- nodes[1].node.handle_tx_abort(node_id_0, &tx_abort);
expect_splice_failed_events(
&nodes[0],
&channel_id,
@@ -7274,6 +7272,333 @@ fn test_splice_rbf_no_pending_splice() {
);
}
+#[cfg(test)]
+enum SpliceConfirmationAbortTest {
+ Connected,
+ Disconnected,
+ Reloaded,
+}
+
+#[cfg(test)]
+fn do_test_splice_confirmation_aborts_rbf(test_case: SpliceConfirmationAbortTest) {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let (persister_0, chain_monitor_0);
+ let node_0;
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, feerate);
+ let acceptor_contribution =
+ do_initiate_splice_in_at_feerate(&nodes[1], &nodes[0], channel_id, added_value, feerate);
+ let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution.clone(),
+ Some(acceptor_contribution),
+ tx_ack_rbf.funding_output_contribution.unwrap_or(0),
+ funding_script,
+ );
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ let _ = get_event!(nodes[0], Event::FundingTransactionReadyForSigning);
+ let _ = get_event!(nodes[1], Event::FundingTransactionReadyForSigning);
+ let is_reloaded = matches!(&test_case, SpliceConfirmationAbortTest::Reloaded);
+ let is_disconnected = matches!(&test_case, SpliceConfirmationAbortTest::Disconnected);
+ let expected_acceptor_rbf_contribution =
+ nodes[1].node.list_channels()[0].splice_details.as_ref().unwrap().candidates[1]
+ .contribution
+ .clone()
+ .expect("acceptor contributed to the RBF");
+ let payment_data = match test_case {
+ SpliceConfirmationAbortTest::Connected | SpliceConfirmationAbortTest::Reloaded => {
+ let (route, payment_hash, payment_preimage, payment_secret) =
+ get_route_and_payment_hash!(nodes[0], nodes[1], 1_000_000);
+ let onion = RecipientOnionFields::secret_only(payment_secret, 1_000_000);
+ nodes[0]
+ .node
+ .send_payment_with_route(route, payment_hash, onion, PaymentId(payment_hash.0))
+ .unwrap();
+ Some((payment_hash, payment_preimage, payment_secret))
+ },
+ SpliceConfirmationAbortTest::Disconnected => {
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ nodes[0].node.peer_disconnected(node_id_1);
+ nodes[1].node.peer_disconnected(node_id_0);
+ None
+ },
+ };
+ if is_reloaded {
+ // Preserve both the resumable AwaitingSignatures RBF and the HTLC held by quiescence, then
+ // reload before the prior candidate confirms. The first chain callbacks run before startup
+ // background events have been processed and therefore cannot release the holding cell.
+ nodes[1].node.peer_disconnected(node_id_0);
+ let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode();
+ reload_node!(
+ nodes[0],
+ nodes[0].node.encode(),
+ &[&encoded_monitor_0],
+ persister_0,
+ chain_monitor_0,
+ node_0
+ );
+ }
+
+ let details = nodes[0].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingSignatures { .. }
+ ));
+
+ // The first confirmation aborts the RBF even though the splice needs more confirmations before
+ // `splice_locked`.
+ mine_transaction(&nodes[0], &first_splice_tx);
+ let details = nodes[0].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 1);
+ let confirmed = details.confirmed_candidate.unwrap();
+ assert!(!confirmed.splice_locked_sent);
+ let mut events = nodes[0].node.get_and_clear_pending_events();
+ if is_reloaded {
+ let signing_event_idx = events
+ .iter()
+ .position(|event| matches!(event, Event::FundingTransactionReadyForSigning { .. }))
+ .expect("AwaitingSignatures should regenerate its signing event after reload");
+ events.remove(signing_event_idx);
+ }
+ assert!(
+ matches!(
+ events.as_slice(),
+ [Event::SpliceNegotiationFailed {
+ channel_id: failed_channel_id,
+ contribution: Some(failed_contribution),
+ reason: NegotiationFailureReason::CannotInitiateRbf,
+ ..
+ }] if *failed_channel_id == channel_id
+ && failed_contribution == &rbf_contribution
+ ),
+ "{events:?}"
+ );
+
+ if is_disconnected || is_reloaded {
+ // The confirmation could not notify the disconnected peer that the active RBF was dropped.
+ // On reconnect, its `next_funding` prompts a `tx_abort` so both sides converge before the
+ // confirmed candidate locks. After a startup confirmation, only then may the held HTLC be
+ // released as a commitment update.
+ if is_reloaded {
+ check_added_monitors(&nodes[0], 0);
+ }
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
+ reconnect_args.send_announcement_sigs = (true, true);
+ reconnect_args.send_tx_abort = (false, true);
+ if is_reloaded {
+ reconnect_args.pending_cell_htlc_adds = (0, 1);
+ }
+ reconnect_nodes(reconnect_args);
+
+ expect_failed_rbf_events(
+ &nodes[1],
+ &channel_id,
+ &expected_acceptor_rbf_contribution,
+ NegotiationFailureReason::CounterpartyAborted {
+ msg: UntrustedString(
+ "Signing was not completed for this funding transaction; it may be forgotten."
+ .to_owned(),
+ ),
+ },
+ );
+ if is_reloaded {
+ let (payment_hash, payment_preimage, payment_secret) =
+ payment_data.expect("reloaded test has payment data");
+ expect_and_process_pending_htlcs(&nodes[1], false);
+ expect_payment_claimable!(nodes[1], payment_hash, payment_secret, 1_000_000);
+ claim_payment(&nodes[0], &[&nodes[1]], payment_preimage);
+ }
+
+ mine_transaction(&nodes[1], &first_splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+ return;
+ }
+
+ let (payment_hash, payment_preimage, payment_secret) =
+ payment_data.expect("connected test has payment data");
+
+ check_added_monitors(&nodes[0], 1);
+
+ let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ let tx_abort = match &msg_events[0] {
+ MessageSendEvent::SendTxAbort { msg, .. } => {
+ assert_eq!(
+ tx_abort_data(msg),
+ "Rejecting RBF attempt: A negotiated splice candidate has confirmed",
+ );
+ msg.clone()
+ },
+ other => panic!("Expected SendTxAbort, got {other:?}"),
+ };
+ let update = match &msg_events[1] {
+ MessageSendEvent::UpdateHTLCs { updates, .. } => updates.clone(),
+ other => panic!("Expected UpdateHTLCs, got {other:?}"),
+ };
+
+ nodes[1].node.handle_tx_abort(node_id_0, &tx_abort);
+ expect_failed_rbf_events(
+ &nodes[1],
+ &channel_id,
+ &expected_acceptor_rbf_contribution,
+ NegotiationFailureReason::CounterpartyAborted {
+ msg: UntrustedString(
+ "Rejecting RBF attempt: A negotiated splice candidate has confirmed".to_owned(),
+ ),
+ },
+ );
+ let tx_abort_ack = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_ack);
+ nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]);
+ do_commitment_signed_dance(&nodes[1], &nodes[0], &update.commitment_signed, false, false);
+ expect_and_process_pending_htlcs(&nodes[1], false);
+ expect_payment_claimable!(nodes[1], payment_hash, payment_secret, 1_000_000);
+ claim_payment(&nodes[0], &[&nodes[1]], payment_preimage);
+
+ mine_transaction(&nodes[1], &first_splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+}
+
+#[test]
+fn test_splice_confirmation_aborts_rbf() {
+ do_test_splice_confirmation_aborts_rbf(SpliceConfirmationAbortTest::Connected);
+}
+
+#[test]
+fn test_splice_confirmation_aborts_rbf_while_disconnected() {
+ do_test_splice_confirmation_aborts_rbf(SpliceConfirmationAbortTest::Disconnected);
+}
+
+#[test]
+fn test_splice_confirmation_aborts_rbf_during_startup() {
+ do_test_splice_confirmation_aborts_rbf(SpliceConfirmationAbortTest::Reloaded);
+}
+
+#[test]
+fn test_splice_confirmation_aborts_rbf_without_local_contribution() {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, feerate);
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ let _crossing_tx_add_input =
+ get_event_msg!(nodes[0], MessageSendEvent::SendTxAddInput, node_id_1);
+
+ // The acceptor has no contribution to return, but still tears down the RBF and notifies its peer.
+ mine_transaction(&nodes[1], &splice_tx);
+ assert!(nodes[1].node.get_and_clear_pending_events().is_empty());
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(
+ tx_abort_data(&tx_abort),
+ "Rejecting RBF attempt: A negotiated splice candidate has confirmed"
+ );
+}
+
+#[test]
+fn test_splice_confirmation_aborts_constructing_rbf_with_prior_signing_session() {
+ // While an RBF is constructing, the retained signing session belongs to the prior candidate and
+ // must not make the new round appear too advanced to abort.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ // Use a fresh contribution so aborting the RBF has funding which must be returned.
+ nodes[0].wallet_source.clear_utxos();
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let rbf_feerate = funding_template.min_rbf_feerate().unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let rbf_contribution = funding_template
+ .without_prior_contribution(rbf_feerate, FeeRate::MAX)
+ .with_coin_selection_source_sync(&wallet)
+ .add_value(added_value)
+ .unwrap()
+ .build()
+ .unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, rbf_contribution.clone(), None)
+ .unwrap();
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+
+ assert!(matches!(
+ nodes[0].node.list_channels()[0].splice_details.as_ref().unwrap().candidates[1].status,
+ SpliceCandidateStatus::ConstructingTransaction { .. }
+ ));
+
+ mine_transaction(&nodes[0], &first_splice_tx);
+ expect_failed_rbf_events(
+ &nodes[0],
+ &channel_id,
+ &rbf_contribution,
+ NegotiationFailureReason::CannotInitiateRbf,
+ );
+
+ let mut msg_events = nodes[0].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ let tx_add_input = match msg_events.remove(0) {
+ MessageSendEvent::SendTxAddInput { msg, .. } => msg,
+ other => panic!("Expected SendTxAddInput, got {other:?}"),
+ };
+ let tx_abort = match msg_events.remove(0) {
+ MessageSendEvent::SendTxAbort { msg, .. } => msg,
+ other => panic!("Expected SendTxAbort, got {other:?}"),
+ };
+
+ // Drain the interactive message which crossed with confirmation before completing the abort.
+ nodes[1].node.handle_tx_add_input(node_id_0, &tx_add_input);
+ let tx_complete = get_event_msg!(nodes[1], MessageSendEvent::SendTxComplete, node_id_0);
+ nodes[1].node.handle_tx_abort(node_id_0, &tx_abort);
+ let tx_abort_ack = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ nodes[0].node.handle_tx_complete(node_id_1, &tx_complete);
+ nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_ack);
+
+ mine_transaction(&nodes[1], &first_splice_tx);
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+}
+
#[test]
fn test_confirmed_splice_candidate_blocks_new_rbf() {
// A candidate cannot be replaced once it confirms. A new splice_channel call must return a fresh
@@ -7400,8 +7725,8 @@ fn test_confirmed_splice_candidate_rejects_inbound_tx_init_rbf() {
lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
}
-#[test]
-fn test_aborted_rbf_ignores_inflight_commitment_signed() {
+#[cfg(test)]
+fn do_test_aborted_rbf_ignores_inflight_commitment_signed(confirmation_aborts_rbf: bool) {
// A peer may sign an RBF while we are aborting it. Its crossing commitment_signed refers only
// to the discarded funding scope and must not close the channel.
let chanmon_cfgs = create_chanmon_cfgs(2);
@@ -7457,21 +7782,41 @@ fn test_aborted_rbf_ignores_inflight_commitment_signed() {
}
let crossing_commitment_signed = get_htlc_update_msgs(&nodes[1], &node_id_0).commitment_signed;
- nodes[0].node.cancel_funding_contributed(&channel_id, &node_id_1).unwrap();
- let events = nodes[0].node.get_and_clear_pending_events();
- assert!(
- matches!(
- events.as_slice(),
- [Event::SpliceNegotiationFailed {
- channel_id: failed_channel_id,
- contribution: Some(failed_contribution),
- reason: NegotiationFailureReason::LocallyCanceled,
- ..
- }] if *failed_channel_id == channel_id
- && failed_contribution == &rbf_contribution
- ),
- "{events:?}"
- );
+ let counterparty_abort_reason = if confirmation_aborts_rbf {
+ mine_transaction(&nodes[0], &first_splice_tx);
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert!(
+ matches!(
+ events.as_slice(),
+ [Event::SpliceNegotiationFailed {
+ channel_id: failed_channel_id,
+ contribution: Some(failed_contribution),
+ reason: NegotiationFailureReason::CannotInitiateRbf,
+ ..
+ }] if *failed_channel_id == channel_id
+ && failed_contribution == &rbf_contribution
+ ),
+ "{events:?}"
+ );
+ "Rejecting RBF attempt: A negotiated splice candidate has confirmed"
+ } else {
+ nodes[0].node.cancel_funding_contributed(&channel_id, &node_id_1).unwrap();
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert!(
+ matches!(
+ events.as_slice(),
+ [Event::SpliceNegotiationFailed {
+ channel_id: failed_channel_id,
+ contribution: Some(failed_contribution),
+ reason: NegotiationFailureReason::LocallyCanceled,
+ ..
+ }] if *failed_channel_id == channel_id
+ && failed_contribution == &rbf_contribution
+ ),
+ "{events:?}"
+ );
+ "Manually aborted funding negotiation"
+ };
let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1);
nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &crossing_commitment_signed);
@@ -7481,17 +7826,148 @@ fn test_aborted_rbf_ignores_inflight_commitment_signed() {
&channel_id,
&expected_acceptor_rbf_contribution,
NegotiationFailureReason::CounterpartyAborted {
- msg: UntrustedString("Manually aborted funding negotiation".to_owned()),
+ msg: UntrustedString(counterparty_abort_reason.to_owned()),
},
);
let tx_abort_ack = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
nodes[0].node.handle_tx_abort(node_id_1, &tx_abort_ack);
- mine_transaction(&nodes[0], &first_splice_tx);
+ if !confirmation_aborts_rbf {
+ mine_transaction(&nodes[0], &first_splice_tx);
+ }
mine_transaction(&nodes[1], &first_splice_tx);
lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
}
+#[test]
+fn test_aborted_rbf_ignores_inflight_commitment_signed() {
+ do_test_aborted_rbf_ignores_inflight_commitment_signed(false);
+ do_test_aborted_rbf_ignores_inflight_commitment_signed(true);
+}
+
+#[cfg(test)]
+fn do_test_splice_confirmation_completes_rbf_after_signing_begins(async_signer: bool) {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0);
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (first_splice_tx, funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, contribution);
+
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
+ let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, feerate);
+ let tx_ack_rbf = complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ rbf_contribution,
+ None,
+ tx_ack_rbf.funding_output_contribution.unwrap_or(0),
+ funding_script,
+ );
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ if async_signer {
+ nodes[0].disable_channel_signer_op(
+ &node_id_1,
+ &channel_id,
+ SignerOp::SignCounterpartyCommitment,
+ );
+ }
+ let rbf_txid = match get_event!(nodes[0], Event::FundingTransactionReadyForSigning) {
+ Event::FundingTransactionReadyForSigning {
+ channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } => {
+ let txid = unsigned_transaction.compute_txid();
+ let partially_signed_tx = nodes[0].wallet_source.sign_tx(unsigned_transaction).unwrap();
+ nodes[0]
+ .node
+ .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx)
+ .unwrap();
+ txid
+ },
+ other => panic!("Unexpected event {other:?}"),
+ };
+
+ // Confirmation must retain the RBF once we have provided funding signatures, including when
+ // our commitment_signed is queued or still waiting on an asynchronous signer.
+ mine_transaction(&nodes[0], &first_splice_tx);
+ let details = nodes[0].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingSignatures { .. }
+ ));
+ if async_signer {
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+ nodes[0].enable_channel_signer_op(
+ &node_id_1,
+ &channel_id,
+ SignerOp::SignCounterpartyCommitment,
+ );
+ nodes[0].node.signer_unblocked(None);
+ }
+ let commitment_signed = get_htlc_update_msgs(&nodes[0], &node_id_1).commitment_signed;
+ nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &commitment_signed);
+ check_added_monitors(&nodes[1], 1);
+
+ // Node 1 has now processed commitment_signed for the RBF. Confirmation of the earlier splice
+ // must not reset it or append tx_abort to the signing messages already queued for node 0.
+ mine_transaction(&nodes[1], &first_splice_tx);
+ let details = nodes[1].node.list_channels()[0].splice_details.clone().unwrap();
+ assert_eq!(details.candidates.len(), 2);
+ assert!(details.confirmed_candidate.is_some());
+ assert!(matches!(
+ details.candidates[1].status,
+ SpliceCandidateStatus::AwaitingSignatures { .. }
+ ));
+ let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
+ assert_eq!(msg_events.len(), 2, "{msg_events:?}");
+ let acceptor_commitment_signed = match &msg_events[0] {
+ MessageSendEvent::UpdateHTLCs { updates, .. } => updates.commitment_signed.clone(),
+ other => panic!("Expected UpdateHTLCs, got {other:?}"),
+ };
+ let acceptor_tx_signatures = match &msg_events[1] {
+ MessageSendEvent::SendTxSignatures { msg, .. } => msg.clone(),
+ other => panic!("Expected SendTxSignatures, got {other:?}"),
+ };
+
+ // Finish the signature exchange after observing that confirmation did not abort this advanced
+ // signing state.
+ nodes[0].node.handle_commitment_signed_batch_test(node_id_1, &acceptor_commitment_signed);
+ check_added_monitors(&nodes[0], 1);
+ nodes[0].node.handle_tx_signatures(node_id_1, &acceptor_tx_signatures);
+ let initiator_tx_signatures =
+ get_event_msg!(nodes[0], MessageSendEvent::SendTxSignatures, node_id_1);
+ nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures);
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+
+ // Finish locking the earlier candidate, discarding the RBF transaction which was allowed to
+ // finish signing above.
+ connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
+ connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
+ let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1);
+ lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[rbf_txid]);
+}
+
+#[test]
+fn test_splice_confirmation_completes_rbf_after_signing_begins() {
+ do_test_splice_confirmation_completes_rbf_after_signing_begins(false);
+ do_test_splice_confirmation_completes_rbf_after_signing_begins(true);
+}
+
#[test]
fn test_pending_rbf_signer_cleared_on_abort() {
let chanmon_cfgs = create_chanmon_cfgs(2);Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.