Contribute to splice as acceptor
What changed, and why it matters
This commit changes how the Lightning Dev Kit handles a rare situation where both sides of a channel try to splice funds at the same time. Previously, the node that lost the tie-breaker would sit out and start a second splice later. Now, the loser contributes its funds to the winner's splice as an 'acceptor,' combining both into one transaction. The change also adjusts fees and change outputs when the winner's chosen fee rate differs from what the loser expected. This is a protocol optimization, not a clear security fix, but it touches fee accounting and transaction construction, which are sensitive areas.
Review the fee-buffer and change-output arithmetic in FundingContribution::net_value_for_acceptor_at_feerate and for_acceptor_at_feerate (not shown in this diff) for off-by-one or rounding issues that could produce an invalid transaction or an unexpectedly small change output. Run the new splicing tie-break tests under sanitizers and with adversarial feerate choices. Monitor for any follow-up fixes that mention fee miscalculation or splice transaction malleability.
Security signals we found
Fee re-estimation at a different feerate for acceptor inputs/outputs
Change-output value adjustment when acceptor contribution is merged
Acceptor contribution dropped if counterparty feerate is below min, above max with higher fee, or exceeds fee buffer
Removal of previous zero-contribution invariant and debug_assert
New test coverage for boundary feerate conditions
Evidence from the diff
The patch enables the splice acceptor to merge its queued FundingContribution into an initiator’s splice when both peers requested splicing simultaneously. It adds queued_funding_contribution/take_queued_funding_contribution accessors, removes the hard-coded zero acceptor contribution, and calls net_value_for_acceptor_at_feerate / for_acceptor_at_feerate to re-derive inputs/outputs and change at the initiator’s feerate. validate_splice_init now accepts a non-zero acceptor contribution. Tests are updated to exercise same-feerate, higher-feerate, lower-feerate, and too-high-feerate scenarios, plus the disconnected-node case. The commit does not describe any vulnerability; it frames the change as a performance optimization for often-offline nodes.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsSplice negotiation / interactive transaction constructionFundingContribution fee adjustment helpers (referenced but not shown in diff)Inspect captured patch +491 / −94
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 2c1117a..9361cd3 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -11851,6 +11851,26 @@ where
self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime })
}
+ /// Returns a reference to the funding contribution queued by a pending [`QuiescentAction`],
+ /// if any.
+ fn queued_funding_contribution(&self) -> Option<&FundingContribution> {
+ match &self.quiescent_action {
+ Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution),
+ _ => None,
+ }
+ }
+
+ /// Consumes and returns the funding contribution from the pending [`QuiescentAction`], if any.
+ fn take_queued_funding_contribution(&mut self) -> Option<FundingContribution> {
+ match &self.quiescent_action {
+ Some(QuiescentAction::Splice { .. }) => match self.quiescent_action.take() {
+ Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution),
+ _ => unreachable!(),
+ },
+ _ => None,
+ }
+ }
+
fn send_splice_init(&mut self, context: FundingNegotiationContext) -> msgs::SpliceInit {
debug_assert!(self.pending_splice.is_none());
// Rotate the funding pubkey using the prev_funding_txid as a tweak
@@ -11948,10 +11968,6 @@ where
));
}
- // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient,
- // similarly to the check in `funding_contributed`.
- debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO);
-
let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis);
if their_funding_contribution == SignedAmount::ZERO {
return Err(ChannelError::WarnAndDisconnect(format!(
@@ -12075,11 +12091,52 @@ where
}
pub(crate) fn splice_init<ES: EntropySource, L: Logger>(
- &mut self, msg: &msgs::SpliceInit, our_funding_contribution_satoshis: i64,
- entropy_source: &ES, holder_node_id: &PublicKey, logger: &L,
+ &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey,
+ logger: &L,
) -> Result<msgs::SpliceAck, ChannelError> {
- let our_funding_contribution = SignedAmount::from_sat(our_funding_contribution_satoshis);
- let splice_funding = self.validate_splice_init(msg, our_funding_contribution)?;
+ let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64);
+ let holder_balance = self
+ .get_holder_counterparty_balances_floor_incl_fee(&self.funding)
+ .map(|(holder, _)| holder)
+ .map_err(|e| {
+ log_info!(
+ logger,
+ "Cannot compute holder balance for channel {}: {}; \
+ proceeding without contribution",
+ self.context.channel_id(),
+ e,
+ );
+ })
+ .ok();
+ let our_funding_contribution =
+ holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| {
+ c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap())
+ .map_err(|e| {
+ log_info!(
+ logger,
+ "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \
+ proceeding without contribution",
+ feerate,
+ self.context.channel_id(),
+ e,
+ );
+ })
+ .ok()
+ });
+
+ let splice_funding =
+ self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?;
+
+ let (our_funding_inputs, our_funding_outputs) = if our_funding_contribution.is_some() {
+ self.take_queued_funding_contribution()
+ .expect("queued_funding_contribution was Some")
+ .for_acceptor_at_feerate(feerate, holder_balance.unwrap())
+ .expect("feerate compatibility already checked")
+ .into_tx_parts()
+ } else {
+ Default::default()
+ };
+ let our_funding_contribution = our_funding_contribution.unwrap_or(SignedAmount::ZERO);
log_info!(
logger,
@@ -12096,8 +12153,8 @@ where
funding_tx_locktime: LockTime::from_consensus(msg.locktime),
funding_feerate_sat_per_1000_weight: msg.funding_feerate_per_kw,
shared_funding_input: Some(prev_funding_input),
- our_funding_inputs: Vec::new(),
- our_funding_outputs: Vec::new(),
+ our_funding_inputs,
+ our_funding_outputs,
};
let (interactive_tx_constructor, first_message) = funding_negotiation_context
@@ -12109,11 +12166,6 @@ where
);
debug_assert!(first_message.is_none());
- // TODO(splicing): if quiescent_action is set, integrate what the user wants to do into the
- // counterparty-initiated splice. For always-on nodes this probably isn't a useful
- // optimization, but for often-offline nodes it may be, as we may connect and immediately
- // go into splicing from both sides.
-
let new_funding_pubkey = splice_funding.get_holder_pubkeys().funding_pubkey;
self.pending_splice = Some(PendingFunding {
funding_negotiation: Some(FundingNegotiation::ConstructingTransaction {
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 19767de..ada27af 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4643,11 +4643,21 @@ impl<
///
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
- /// [`FundingContribution`], fees are estimated using `min_feerate` and must be covered by the
- /// supplied inputs for splice-in or the channel balance for splice-out. If the counterparty
- /// also initiates a splice and wins the tie-break, they become the initiator and choose the
- /// feerate. In that case, `max_feerate` is used to reject a feerate that is too high for our
- /// contribution.
+ /// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
+ /// responsibility and must be covered by the supplied inputs for splice-in or the channel
+ /// balance for splice-out. If the counterparty also initiates a splice and wins the
+ /// tie-break, they become the initiator and choose the feerate. The fee is then
+ /// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
+ /// which may be higher or lower than the original estimate. The contribution is dropped and
+ /// the splice proceeds without it when:
+ /// - the counterparty's feerate is below `min_feerate`
+ /// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
+ /// original fee estimate
+ /// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
+ ///
+ /// The fee buffer is the maximum fee that can be accommodated:
+ /// - **splice-in**: the selected inputs' value minus the contributed amount
+ /// - **splice-out**: the channel balance minus the withdrawal outputs
///
/// Returns a [`FundingTemplate`] which should be used to build a [`FundingContribution`] via
/// one of its splice methods (e.g., [`FundingTemplate::splice_in_sync`]). The resulting
@@ -12826,9 +12836,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
let mut peer_state_lock = peer_state_mutex.lock().unwrap();
let peer_state = &mut *peer_state_lock;
- // TODO(splicing): Currently not possible to contribute on the splicing-acceptor side
- let our_funding_contribution = 0i64;
-
// Look for the channel
match peer_state.channel_by_id.entry(msg.channel_id) {
hash_map::Entry::Vacant(_) => {
@@ -12848,7 +12855,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() {
let init_res = funded_channel.splice_init(
msg,
- our_funding_contribution,
&self.entropy_source,
&self.get_our_node_id(),
&self.logger,
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 70b347e..b45c3ce 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -315,6 +315,23 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>(
pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
initiator_contribution: FundingContribution, new_funding_script: ScriptBuf,
+) {
+ complete_interactive_funding_negotiation_for_both(
+ initiator,
+ acceptor,
+ channel_id,
+ initiator_contribution,
+ None,
+ 0,
+ new_funding_script,
+ );
+}
+
+pub fn complete_interactive_funding_negotiation_for_both<'a, 'b, 'c, 'd>(
+ initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId,
+ initiator_contribution: FundingContribution,
+ acceptor_contribution: Option<FundingContribution>, acceptor_funding_satoshis: i64,
+ new_funding_script: ScriptBuf,
) {
let node_id_initiator = initiator.node.get_our_node_id();
let node_id_acceptor = acceptor.node.get_our_node_id();
@@ -331,6 +348,8 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
let new_channel_value = Amount::from_sat(
channel_value_satoshis
.checked_add_signed(initiator_contribution.net_value().to_sat())
+ .unwrap()
+ .checked_add_signed(acceptor_funding_satoshis)
.unwrap(),
);
let (initiator_funding_tx_inputs, mut expected_initiator_outputs) =
@@ -343,8 +362,22 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
expected_initiator_outputs
.push(TxOut { script_pubkey: new_funding_script, value: new_channel_value });
+ let (mut expected_acceptor_inputs, mut expected_acceptor_scripts) =
+ if let Some(acceptor_contribution) = acceptor_contribution {
+ let (acceptor_inputs, acceptor_outputs) = acceptor_contribution.into_tx_parts();
+ let expected_acceptor_inputs =
+ acceptor_inputs.iter().map(|input| input.utxo.outpoint).collect::<Vec<_>>();
+ let expected_acceptor_scripts =
+ acceptor_outputs.into_iter().map(|output| output.script_pubkey).collect::<Vec<_>>();
+ (expected_acceptor_inputs, expected_acceptor_scripts)
+ } else {
+ (Vec::new(), Vec::new())
+ };
+
let mut acceptor_sent_tx_complete = false;
+ let mut initiator_sent_tx_complete;
loop {
+ // Initiator's turn: send TxAddInput, TxAddOutput, or TxComplete
if !expected_initiator_inputs.is_empty() {
let tx_add_input =
get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor);
@@ -361,6 +394,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
expected_initiator_inputs.iter().position(|input| *input == input_prevout).unwrap(),
);
acceptor.node.handle_tx_add_input(node_id_initiator, &tx_add_input);
+ initiator_sent_tx_complete = false;
} else if !expected_initiator_outputs.is_empty() {
let tx_add_output =
get_event_msg!(initiator, MessageSendEvent::SendTxAddOutput, node_id_acceptor);
@@ -374,6 +408,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
.unwrap(),
);
acceptor.node.handle_tx_add_output(node_id_initiator, &tx_add_output);
+ initiator_sent_tx_complete = false;
} else {
let msg_events = initiator.node.get_and_clear_pending_msg_events();
assert_eq!(msg_events.len(), 1, "{msg_events:?}");
@@ -382,24 +417,69 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>(
} else {
panic!();
}
+ initiator_sent_tx_complete = true;
if acceptor_sent_tx_complete {
break;
}
}
- let mut msg_events = acceptor.node.get_and_clear_pending_msg_events();
+ // Acceptor's turn: send TxAddInput, TxAddOutput, or TxComplete
+ let msg_events = acceptor.node.get_and_clear_pending_msg_events();
assert_eq!(msg_events.len(), 1, "{msg_events:?}");
- if let MessageSendEvent::SendTxComplete { ref msg, .. } = msg_events.remove(0) {
- initiator.node.handle_tx_complete(node_id_acceptor, msg);
- } else {
- panic!();
+ match &msg_events[0] {
+ MessageSendEvent::SendTxAddInput { msg, .. } => {
+ let input_prevout = BitcoinOutPoint {
+ txid: msg
+ .prevtx
+ .as_ref()
+ .map(|prevtx| prevtx.compute_txid())
+ .or(msg.shared_input_txid)
+ .unwrap(),
+ vout: msg.prevtx_out,
+ };
+ expected_acceptor_inputs.remove(
+ expected_acceptor_inputs
+ .iter()
+ .position(|input| *input == input_prevout)
+ .unwrap(),
+ );
+ initiator.node.handle_tx_add_input(node_id_acceptor, msg);
+ acceptor_sent_tx_complete = false;
+ },
+ MessageSendEvent::SendTxAddOutput { msg, .. } => {
+ expected_acceptor_scripts.remove(
+ expected_acceptor_scripts
+ .iter()
+ .position(|script| *script == msg.script)
+ .unwrap(),
+ );
+ initiator.node.handle_tx_add_output(node_id_acceptor, msg);
+ acceptor_sent_tx_complete = false;
+ },
+ MessageSendEvent::SendTxComplete { msg, .. } => {
+ initiator.node.handle_tx_complete(node_id_acceptor, msg);
+ acceptor_sent_tx_complete = true;
+ if initiator_sent_tx_complete {
+ break;
+ }
+ },
+ _ => panic!("Unexpected message event: {:?}", msg_events[0]),
}
- acceptor_sent_tx_complete = true;
}
+
+ assert!(expected_acceptor_inputs.is_empty(), "Not all acceptor inputs were sent");
+ assert!(expected_acceptor_scripts.is_empty(), "Not all acceptor outputs were sent");
}
pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>(
initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool,
+) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) {
+ sign_interactive_funding_tx_with_acceptor_contribution(initiator, acceptor, is_0conf, false)
+}
+
+pub fn sign_interactive_funding_tx_with_acceptor_contribution<'a, 'b, 'c, 'd>(
+ initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, is_0conf: bool,
+ acceptor_has_contribution: bool,
) -> (Transaction, Option<(msgs::SpliceLocked, PublicKey)>) {
let node_id_initiator = initiator.node.get_our_node_id();
let node_id_acceptor = acceptor.node.get_our_node_id();
@@ -433,6 +513,29 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>(
};
acceptor.node.handle_commitment_signed(node_id_initiator, &initial_commit_sig_for_acceptor);
+ if acceptor_has_contribution {
+ // When the acceptor contributed inputs, it needs to sign as well. The counterparty's
+ // commitment_signed is buffered until the acceptor signs.
+ assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty());
+
+ let event = get_event!(acceptor, Event::FundingTransactionReadyForSigning);
+ if let Event::FundingTransactionReadyForSigning {
+ channel_id,
+ counterparty_node_id,
+ unsigned_transaction,
+ ..
+ } = event
+ {
+ let partially_signed_tx = acceptor.wallet_source.sign_tx(unsigned_transaction).unwrap();
+ acceptor
+ .node
+ .funding_transaction_signed(&channel_id, &counterparty_node_id, partially_signed_tx)
+ .unwrap();
+ } else {
+ panic!();
+ }
+ }
+
let msg_events = acceptor.node.get_and_clear_pending_msg_events();
assert_eq!(msg_events.len(), 2, "{msg_events:?}");
if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] {
@@ -1301,6 +1404,292 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
);
}
+#[test]
+fn test_splice_both_contribute_tiebreak() {
+ // Same feerate: the acceptor's change increases because is_initiator=false has lower weight.
+ let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ do_test_splice_tiebreak(feerate, feerate, Amount::from_sat(50_000), true);
+}
+
+#[test]
+fn test_splice_tiebreak_higher_feerate() {
+ // Node 0 (winner) uses a higher feerate than node 1 (loser). Node 1's change output is
+ // adjusted (reduced) to accommodate the higher feerate. Negotiation succeeds.
+ let feerate = FEERATE_FLOOR_SATS_PER_KW as u64;
+ do_test_splice_tiebreak(
+ FeeRate::from_sat_per_kwu(feerate * 3),
+ FeeRate::from_sat_per_kwu(feerate),
+ Amount::from_sat(50_000),
+ true,
+ );
+}
+
+#[test]
+fn test_splice_tiebreak_lower_feerate() {
+ // Node 0 (winner) uses a lower feerate than node 1 (loser). Since the initiator's feerate
+ // is below node 1's minimum, node 1 proceeds without contribution and retries as initiator.
+ let feerate = FEERATE_FLOOR_SATS_PER_KW as u64;
+ do_test_splice_tiebreak(
+ FeeRate::from_sat_per_kwu(feerate),
+ FeeRate::from_sat_per_kwu(feerate * 3),
+ Amount::from_sat(50_000),
+ false,
+ );
+}
+
+#[test]
+fn test_splice_tiebreak_feerate_too_high() {
+ // Node 0 (winner) uses a high feerate (20,000 sat/kwu). Node 1 splices in 95,000 sats from
+ // a 100,000 sat UTXO, leaving too little budget for fees. Node 1 proceeds without its
+ // contribution and retries as initiator.
+ let feerate = FEERATE_FLOOR_SATS_PER_KW as u64;
+ do_test_splice_tiebreak(
+ FeeRate::from_sat_per_kwu(20_000),
+ FeeRate::from_sat_per_kwu(feerate),
+ Amount::from_sat(95_000),
+ false,
+ );
+}
+
+/// Runs the splice tie-breaker test with the given per-node feerates and node 1's splice value.
+///
+/// Both nodes call splice_channel + splice_in_sync + funding_contributed, both send STFU,
+/// node 0 wins the tie-break. If `expect_acceptor_contributes` is true, node 1 contributes
+/// to the splice; otherwise, node 1 proceeds without contribution and retries as initiator.
+#[cfg(test)]
+fn do_test_splice_tiebreak(
+ node_0_feerate: FeeRate, node_1_feerate: FeeRate, node_1_splice_value: Amount,
+ expect_acceptor_contributes: 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 node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000));
+
+ // Node 0 calls splice_channel + splice_in_sync + funding_contributed.
+ let funding_template_0 = nodes[0]
+ .node
+ .splice_channel(&channel_id, &node_id_1, node_0_feerate, FeeRate::MAX)
+ .unwrap();
+ let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let node_0_funding_contribution =
+ funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None)
+ .unwrap();
+
+ // Node 1 calls splice_channel + splice_in_sync + funding_contributed.
+ let funding_template_1 = nodes[1]
+ .node
+ .splice_channel(&channel_id, &node_id_0, node_1_feerate, FeeRate::MAX)
+ .unwrap();
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let node_1_funding_contribution =
+ funding_template_1.splice_in_sync(node_1_splice_value, &wallet_1).unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None)
+ .unwrap();
+
+ // Both nodes emit STFU.
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ assert!(stfu_0.initiator);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ assert!(stfu_1.initiator);
+
+ // Tie-break: node 1 handles node 0's STFU first — node 1 loses (not the outbound funder).
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Node 0 handles node 1's STFU — node 0 wins (outbound funder), sends SpliceInit.
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+
+ // Node 1 handles SpliceInit — whether it contributes depends on feerate/budget constraints.
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ let acceptor_contributes = splice_ack.funding_contribution_satoshis != 0;
+ assert_eq!(
+ acceptor_contributes, expect_acceptor_contributes,
+ "Expected acceptor contribution: {}, got: {}",
+ expect_acceptor_contributes, acceptor_contributes,
+ );
+
+ // Node 0 handles SpliceAck — starts interactive tx construction.
+ nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
+
+ // Compute the new funding script from the splice pubkeys.
+ let new_funding_script = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ if acceptor_contributes {
+ // Capture change output values for assertions.
+ let node_0_change = node_0_funding_contribution
+ .change_output()
+ .expect("splice-in should have a change output")
+ .clone();
+ let node_1_change = node_1_funding_contribution
+ .change_output()
+ .expect("splice-in should have a change output")
+ .clone();
+
+ // Complete interactive funding negotiation with both parties' inputs/outputs.
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ node_0_funding_contribution,
+ Some(node_1_funding_contribution),
+ splice_ack.funding_contribution_satoshis,
+ new_funding_script,
+ );
+
+ // Sign (acceptor has contribution) and broadcast.
+ let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution(
+ &nodes[0], &nodes[1], false, true,
+ );
+ assert!(splice_locked.is_none());
+
+ // The initiator's change output should remain unchanged (no feerate adjustment).
+ let initiator_change_in_tx = tx
+ .output
+ .iter()
+ .find(|o| o.script_pubkey == node_0_change.script_pubkey)
+ .expect("Initiator's change output should be in the splice transaction");
+ assert_eq!(
+ initiator_change_in_tx.value, node_0_change.value,
+ "Initiator's change output should remain unchanged",
+ );
+
+ // The acceptor's change output should be adjusted based on the feerate difference.
+ let acceptor_change_in_tx = tx
+ .output
+ .iter()
+ .find(|o| o.script_pubkey == node_1_change.script_pubkey)
+ .expect("Acceptor's change output should be in the splice transaction");
+ if node_0_feerate <= node_1_feerate {
+ // Initiator's feerate <= acceptor's original: the acceptor's change increases because
+ // is_initiator=false has lower weight, and the feerate is the same or lower.
+ assert!(
+ acceptor_change_in_tx.value > node_1_change.value,
+ "Acceptor's change should increase when initiator feerate ({}) <= acceptor \
+ feerate ({}): adjusted {} vs original {}",
+ node_0_feerate.to_sat_per_kwu(),
+ node_1_feerate.to_sat_per_kwu(),
+ acceptor_change_in_tx.value,
+ node_1_change.value,
+ );
+ } else {
+ // Initiator's feerate > acceptor's original: the higher feerate more than compensates
+ // for the lower weight, so the acceptor's change decreases.
+ assert!(
+ acceptor_change_in_tx.value < node_1_change.value,
+ "Acceptor's change should decrease when initiator feerate ({}) > acceptor \
+ feerate ({}): adjusted {} vs original {}",
+ node_0_feerate.to_sat_per_kwu(),
+ node_1_feerate.to_sat_per_kwu(),
+ acceptor_change_in_tx.value,
+ node_1_change.value,
+ );
+ }
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ mine_transaction(&nodes[0], &tx);
+ mine_transaction(&nodes[1], &tx);
+
+ lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+ } else {
+ // Acceptor does not contribute — complete with only node 0's inputs/outputs.
+ complete_interactive_funding_negotiation_for_both(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ node_0_funding_contribution,
+ None,
+ 0,
+ new_funding_script,
+ );
+
+ // Sign (no acceptor contribution) and broadcast.
+ let (tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution(
+ &nodes[0], &nodes[1], false, false,
+ );
+ assert!(splice_locked.is_none());
+
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+
+ mine_transaction(&nodes[0], &tx);
+ mine_transaction(&nodes[1], &tx);
+
+ // After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
+ let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+ let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu {
+ assert!(msg.initiator);
+ msg
+ } else {
+ panic!("Expected SendStfu from node 1 after splice_locked");
+ };
+
+ // === Part 2: Node 1 retries as initiator at its preferred feerate ===
+ // TODO(splicing): Node 1 should retry contribution via RBF above instead
+
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0);
+
+ nodes[0].node.handle_splice_init(node_id_1, &splice_init);
+ let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1);
+
+ nodes[1].node.handle_splice_ack(node_id_0, &splice_ack);
+
+ let new_funding_script_2 = chan_utils::make_funding_redeemscript(
+ &splice_init.funding_pubkey,
+ &splice_ack.funding_pubkey,
+ )
+ .to_p2wsh();
+
+ complete_interactive_funding_negotiation(
+ &nodes[1],
+ &nodes[0],
+ channel_id,
+ node_1_funding_contribution,
+ new_funding_script_2,
+ );
+
+ let (new_splice_tx, splice_locked) =
+ sign_interactive_funding_tx(&nodes[1], &nodes[0], false);
+ assert!(splice_locked.is_none());
+
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+
+ mine_transaction(&nodes[1], &new_splice_tx);
+ mine_transaction(&nodes[0], &new_splice_tx);
+
+ lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1);
+ }
+}
+
#[cfg(test)]
#[derive(PartialEq)]
enum SpliceStatus {
@@ -1777,8 +2166,8 @@ fn test_propose_splice_while_disconnected() {
#[cfg(test)]
fn do_test_propose_splice_while_disconnected(use_0conf: bool) {
// Test that both nodes are able to propose a splice while the counterparty is disconnected, and
- // whoever doesn't go first due to the quiescence tie-breaker, will retry their splice after the
- // first one becomes locked.
+ // whoever doesn't go first due to the quiescence tie-breaker, will have their contribution
+ // merged into the counterparty-initiated splice.
let chanmon_cfgs = create_chanmon_cfgs(2);
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
let mut config = test_default_channel_config();
@@ -1858,23 +2247,29 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) {
.map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script()))
.unwrap();
- // Negotiate the first splice to completion.
+ // Negotiate the splice to completion. Node 1's quiescent action should be consumed by
+ // splice_init, so both contributions are merged into a single splice.
nodes[1].node.handle_splice_init(node_id_0, &splice_init);
let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+ assert_ne!(splice_ack.funding_contribution_satoshis, 0);
nodes[0].node.handle_splice_ack(node_id_1, &splice_ack);
let new_funding_script = chan_utils::make_funding_redeemscript(
&splice_init.funding_pubkey,
&splice_ack.funding_pubkey,
)
.to_p2wsh();
- complete_interactive_funding_negotiation(
+ complete_interactive_funding_negotiation_for_both(
&nodes[0],
&nodes[1],
channel_id,
node_0_funding_contribution,
+ Some(node_1_funding_contribution),
+ splice_ack.funding_contribution_satoshis,
new_funding_script,
);
- let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], use_0conf);
+ let (splice_tx, splice_locked) = sign_interactive_funding_tx_with_acceptor_contribution(
+ &nodes[0], &nodes[1], use_0conf, true,
+ );
expect_splice_pending_event(&nodes[0], &node_id_1);
expect_splice_pending_event(&nodes[1], &node_id_0);
@@ -1888,7 +2283,7 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) {
mine_transaction(&nodes[0], &splice_tx);
mine_transaction(&nodes[1], &splice_tx);
- // Mine enough blocks for the first splice to become locked.
+ // Mine enough blocks for the splice to become locked.
connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1);
connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1);
@@ -1896,10 +2291,9 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) {
};
nodes[1].node.handle_splice_locked(node_id_0, &splice_locked);
- // We should see the node which lost the tie-breaker attempt their splice now by first
- // negotiating quiescence, but their `stfu` won't be sent until after another reconnection.
+ // Node 1's quiescent action was consumed, so it should NOT send stfu.
let msg_events = nodes[1].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), if use_0conf { 2 } else { 3 }, "{msg_events:?}");
+ assert_eq!(msg_events.len(), if use_0conf { 1 } else { 2 }, "{msg_events:?}");
if let MessageSendEvent::SendSpliceLocked { ref msg, .. } = &msg_events[0] {
nodes[0].node.handle_splice_locked(node_id_1, msg);
if use_0conf {
@@ -1920,10 +2314,6 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) {
panic!("Unexpected event {:?}", &msg_events[1]);
}
}
- assert!(matches!(
- &msg_events[if use_0conf { 1 } else { 2 }],
- MessageSendEvent::SendStfu { .. }
- ));
let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
assert_eq!(msg_events.len(), if use_0conf { 0 } else { 2 }, "{msg_events:?}");
@@ -1956,57 +2346,6 @@ fn do_test_propose_splice_while_disconnected(use_0conf: bool) {
.chain_source
.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script);
- // Reconnect the nodes. This should trigger the node which lost the tie-breaker to resend `stfu`
- // for their splice attempt.
- nodes[0].node.peer_disconnected(node_id_1);
- nodes[1].node.peer_disconnected(node_id_0);
- let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
- if !use_0conf {
- reconnect_args.send_announcement_sigs = (true, true);
- }
- reconnect_args.send_stfu = (true, false);
- reconnect_nodes(reconnect_args);
-
- // Drive the second splice to completion.
- let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
- assert_eq!(msg_events.len(), 1, "{msg_events:?}");
- if let MessageSendEvent::SendStfu { ref msg, .. } = msg_events[0] {
- nodes[1].node.handle_stfu(node_id_0, msg);
- } else {
- panic!("Unexpected event {:?}", &msg_events[0]);
- }
-
- let splice_init = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceInit, node_id_0);
- nodes[0].node.handle_splice_init(node_id_1, &splice_init);
- let splice_ack = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceAck, node_id_1);
- nodes[1].node.handle_splice_ack(node_id_0, &splice_ack);
- let new_funding_script = chan_utils::make_funding_redeemscript(
- &splice_init.funding_pubkey,
- &splice_ack.funding_pubkey,
- )
- .to_p2wsh();
- complete_interactive_funding_negotiation(
- &nodes[1],
- &nodes[0],
- channel_id,
- node_1_funding_contribution,
- new_funding_script,
- );
- let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], use_0conf);
- expect_splice_pending_event(&nodes[0], &node_id_1);
- expect_splice_pending_event(&nodes[1], &node_id_0);
-
- if use_0conf {
- let (splice_locked, for_node_id) = splice_locked.unwrap();
- assert_eq!(for_node_id, node_id_0);
- lock_splice(&nodes[1], &nodes[0], &splice_locked, true);
- } else {
- assert!(splice_locked.is_none());
- mine_transaction(&nodes[0], &splice_tx);
- mine_transaction(&nodes[1], &splice_tx);
- lock_splice_after_blocks(&nodes[1], &nodes[0], ANTI_REORG_DELAY - 1);
- }
-
// Sanity check that we can still make a test payment.
send_payment(&nodes[0], &[&nodes[1]], 1_000_000);
}
Why this scored 33/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.