Push splice initiation through the quiescent pipeline
What changed, and why it matters
This commit changes how Lightning channel 'splicing' (a way to resize a payment channel's on-chain funds) is started. Instead of immediately sending a splice request, the code now first enters a 'quiet' (quiescent) state where both sides pause normal updates, then sends the splice request once both sides agree. It also adds serialization support for new internal data structures so node state can be saved and restored. There is no direct evidence in the commit that this fixes a security vulnerability; it appears to be a protocol-correctness and state-management improvement for an in-development splicing feature.
Treat as a normal feature/architecture commit. Reviewers should verify that the quiescence completion path always clears `quiescent_action` on error, that `SpliceInstructions` serialization is backward-compatible, and that the new `DoNothing` variant gating under `#[cfg(any(test, fuzzing))]` does not break production state persistence. No urgent security action is indicated by the diff alone.
Security signals we found
State-machine change: splice initiation now requires mutual quiescence, reducing risk of races/interleaving with HTLC updates
Serialization added for new splice-related state, improving crash-recovery consistency
Error handling tightened: duplicate splice/quiescent actions are rejected via `quiescent_action.is_some()` checks
No explicit security bug fixed; commit is architectural/protocol alignment
Evidence from the diff
The patch refactors splice initiation to flow through the existing quiescence pipeline. Channel::splice_channel no longer directly builds and returns a SpliceInit message; instead it records a new QuiescentAction::Splice(SpliceInstructions), calls propose_quiescence, and returns an optional Stfu message. A new send_splice_init method is invoked once quiescence is reached. The StfuResponse enum is introduced so the stfu handler can return either a reciprocal Stfu or a SpliceInit. Serialization macros are added for Utxo, FundingTxInput, SpliceInstructions, and SignedAmount/Sequence, and tests are updated to expect the two-step STFU exchange before SpliceInit.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/funding.rslightning/src/events/bump_transaction/mod.rslightning/src/util/ser.rslightning/src/ln/splicing_tests.rsInspect captured patch +180 / −48
diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs
index 6f12769..fb872d6 100644
--- a/lightning/src/events/bump_transaction/mod.rs
+++ b/lightning/src/events/bump_transaction/mod.rs
@@ -264,6 +264,12 @@ pub struct Utxo {
pub satisfaction_weight: u64,
}
+impl_writeable_tlv_based!(Utxo, {
+ (1, outpoint, required),
+ (3, output, required),
+ (5, satisfaction_weight, required),
+});
+
impl Utxo {
/// Returns a `Utxo` with the `satisfaction_weight` estimate for a legacy P2PKH output.
pub fn new_p2pkh(outpoint: OutPoint, value: Amount, pubkey_hash: &PubkeyHash) -> Self {
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 6cff41b..47eea77 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -2448,13 +2448,46 @@ impl PendingSplice {
}
}
+pub(crate) struct SpliceInstructions {
+ adjusted_funding_contribution: SignedAmount,
+ our_funding_inputs: Vec<FundingTxInput>,
+ our_funding_outputs: Vec<TxOut>,
+ change_script: Option<ScriptBuf>,
+ funding_feerate_per_kw: u32,
+ locktime: u32,
+ original_funding_txo: OutPoint,
+}
+
+impl_writeable_tlv_based!(SpliceInstructions, {
+ (1, adjusted_funding_contribution, required),
+ (3, our_funding_inputs, required_vec),
+ (5, our_funding_outputs, required_vec),
+ (7, change_script, option),
+ (9, funding_feerate_per_kw, required),
+ (11, locktime, required),
+ (13, original_funding_txo, required),
+});
+
pub(crate) enum QuiescentAction {
- // TODO: Make this test-only once we have another variant (as some code requires *a* variant).
+ Splice(SpliceInstructions),
+ #[cfg(any(test, fuzzing))]
DoNothing,
}
+pub(crate) enum StfuResponse {
+ Stfu(msgs::Stfu),
+ #[cfg_attr(not(splicing), allow(unused))]
+ SpliceInit(msgs::SpliceInit),
+}
+
+#[cfg(any(test, fuzzing))]
impl_writeable_tlv_based_enum_upgradable!(QuiescentAction,
- (99, DoNothing) => {},
+ (0, DoNothing) => {},
+ {1, Splice} => (),
+);
+#[cfg(not(any(test, fuzzing)))]
+impl_writeable_tlv_based_enum_upgradable!(QuiescentAction,,
+ {1, Splice} => (),
);
/// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`].
@@ -10748,9 +10781,13 @@ where
/// - `change_script`: an option change output script. If `None` and needed, one will be
/// generated by `SignerProvider::get_destination_script`.
#[cfg(splicing)]
- pub fn splice_channel(
+ pub fn splice_channel<L: Deref>(
&mut self, contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: u32,
- ) -> Result<msgs::SpliceInit, APIError> {
+ logger: &L,
+ ) -> Result<Option<msgs::Stfu>, APIError>
+ where
+ L::Target: Logger,
+ {
if self.holder_commitment_point.current_point().is_none() {
return Err(APIError::APIMisuseError {
err: format!(
@@ -10762,7 +10799,7 @@ where
// Check if a splice has been initiated already.
// Note: only a single outstanding splice is supported (per spec)
- if self.pending_splice.is_some() {
+ if self.pending_splice.is_some() || self.quiescent_action.is_some() {
return Err(APIError::APIMisuseError {
err: format!(
"Channel {} cannot be spliced, as it has already a splice pending",
@@ -10780,8 +10817,6 @@ where
});
}
- // TODO(splicing): check for quiescence
-
let our_funding_contribution = contribution.value();
if our_funding_contribution == SignedAmount::ZERO {
return Err(APIError::APIMisuseError {
@@ -10876,8 +10911,50 @@ where
}
}
- let prev_funding_input = self.funding.to_splice_funding_input();
+ let original_funding_txo = self.funding.get_funding_txo().ok_or_else(|| {
+ debug_assert!(false);
+ APIError::APIMisuseError { err: "Channel isn't yet fully funded".to_owned() }
+ })?;
+
let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts();
+
+ let action = QuiescentAction::Splice(SpliceInstructions {
+ adjusted_funding_contribution,
+ our_funding_inputs,
+ our_funding_outputs,
+ change_script,
+ funding_feerate_per_kw,
+ locktime,
+ original_funding_txo,
+ });
+ self.propose_quiescence(logger, action)
+ .map_err(|e| APIError::APIMisuseError { err: e.to_owned() })
+ }
+
+ #[cfg(splicing)]
+ fn send_splice_init(
+ &mut self, instructions: SpliceInstructions,
+ ) -> Result<msgs::SpliceInit, String> {
+ let SpliceInstructions {
+ adjusted_funding_contribution,
+ our_funding_inputs,
+ our_funding_outputs,
+ change_script,
+ funding_feerate_per_kw,
+ locktime,
+ original_funding_txo,
+ } = instructions;
+
+ // Check if a splice has been initiated already.
+ // Note: only a single outstanding splice is supported (per spec)
+ if self.pending_splice.is_some() {
+ return Err(format!(
+ "Channel {} cannot be spliced, as it has already a splice pending",
+ self.context.channel_id(),
+ ));
+ }
+
+ let prev_funding_input = self.funding.to_splice_funding_input();
let funding_negotiation_context = FundingNegotiationContext {
is_initiator: true,
our_funding_contribution: adjusted_funding_contribution,
@@ -11820,23 +11897,21 @@ where
);
}
- #[cfg(any(test, fuzzing))]
+ #[cfg(any(splicing, test, fuzzing))]
#[rustfmt::skip]
pub fn propose_quiescence<L: Deref>(
&mut self, logger: &L, action: QuiescentAction,
- ) -> Result<Option<msgs::Stfu>, ChannelError>
+ ) -> Result<Option<msgs::Stfu>, &'static str>
where
L::Target: Logger,
{
log_debug!(logger, "Attempting to initiate quiescence");
if !self.context.is_usable() {
- return Err(ChannelError::Ignore(
- "Channel is not in a usable state to propose quiescence".to_owned()
- ));
+ return Err("Channel is not in a usable state to propose quiescence");
}
if self.quiescent_action.is_some() {
- return Err(ChannelError::Ignore("Channel is already quiescing".to_owned()));
+ return Err("Channel already has a pending quiescent action and cannot start another");
}
self.quiescent_action = Some(action);
@@ -11857,7 +11932,7 @@ where
// Assumes we are either awaiting quiescence or our counterparty has requested quiescence.
#[rustfmt::skip]
- pub fn send_stfu<L: Deref>(&mut self, logger: &L) -> Result<msgs::Stfu, ChannelError>
+ pub fn send_stfu<L: Deref>(&mut self, logger: &L) -> Result<msgs::Stfu, &'static str>
where
L::Target: Logger,
{
@@ -11871,9 +11946,7 @@ where
if self.context.is_waiting_on_peer_pending_channel_update()
|| self.context.is_monitor_or_signer_pending_channel_update()
{
- return Err(ChannelError::Ignore(
- "We cannot send `stfu` while state machine is pending".to_owned()
- ));
+ return Err("We cannot send `stfu` while state machine is pending")
}
let initiator = if self.context.channel_state.is_remote_stfu_sent() {
@@ -11899,7 +11972,7 @@ where
#[rustfmt::skip]
pub fn stfu<L: Deref>(
&mut self, msg: &msgs::Stfu, logger: &L
- ) -> Result<Option<msgs::Stfu>, ChannelError> where L::Target: Logger {
+ ) -> Result<Option<StfuResponse>, ChannelError> where L::Target: Logger {
if self.context.channel_state.is_quiescent() {
return Err(ChannelError::Warn("Channel is already quiescent".to_owned()));
}
@@ -11930,7 +12003,10 @@ where
self.context.channel_state.set_remote_stfu_sent();
log_debug!(logger, "Received counterparty stfu proposing quiescence");
- return self.send_stfu(logger).map(|stfu| Some(stfu));
+ return self
+ .send_stfu(logger)
+ .map(|stfu| Some(StfuResponse::Stfu(stfu)))
+ .map_err(|e| ChannelError::Ignore(e.to_owned()));
}
// We already sent `stfu` and are now processing theirs. It may be in response to ours, or
@@ -11971,6 +12047,13 @@ where
"Internal Error: Didn't have anything to do after reaching quiescence".to_owned()
));
},
+ Some(QuiescentAction::Splice(_instructions)) => {
+ #[cfg(splicing)]
+ return self.send_splice_init(_instructions)
+ .map(|splice_init| Some(StfuResponse::SpliceInit(splice_init)))
+ .map_err(|e| ChannelError::WarnAndDisconnect(e.to_owned()));
+ },
+ #[cfg(any(test, fuzzing))]
Some(QuiescentAction::DoNothing) => {
// In quiescence test we want to just hang out here, letting the test manually
// leave quiescence.
@@ -12003,7 +12086,10 @@ where
|| (self.context.channel_state.is_remote_stfu_sent()
&& !self.context.channel_state.is_local_stfu_sent())
{
- return self.send_stfu(logger).map(|stfu| Some(stfu));
+ return self
+ .send_stfu(logger)
+ .map(|stfu| Some(stfu))
+ .map_err(|e| ChannelError::Ignore(e.to_owned()));
}
// We're either:
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index c953e39..89992dd 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -61,7 +61,7 @@ use crate::ln::channel::QuiescentAction;
use crate::ln::channel::{
self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, FundedChannel,
InboundV1Channel, OutboundV1Channel, PendingV2Channel, ReconnectionMsg, ShutdownResult,
- UpdateFulfillCommitFetch, WithChannelContext,
+ StfuResponse, UpdateFulfillCommitFetch, WithChannelContext,
};
use crate::ln::channel_state::ChannelDetails;
#[cfg(splicing)]
@@ -4494,12 +4494,19 @@ where
hash_map::Entry::Occupied(mut chan_phase_entry) => {
let locktime = locktime.unwrap_or_else(|| self.current_best_block().height);
if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() {
- let msg =
- chan.splice_channel(contribution, funding_feerate_per_kw, locktime)?;
- peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceInit {
- node_id: *counterparty_node_id,
- msg,
- });
+ let logger = WithChannelContext::from(&self.logger, &chan.context, None);
+ let msg_opt = chan.splice_channel(
+ contribution,
+ funding_feerate_per_kw,
+ locktime,
+ &&logger,
+ )?;
+ if let Some(msg) = msg_opt {
+ peer_state.pending_msg_events.push(MessageSendEvent::SendStfu {
+ node_id: *counterparty_node_id,
+ msg,
+ });
+ }
Ok(())
} else {
Err(APIError::ChannelUnavailable {
@@ -10875,7 +10882,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
));
}
- let mut sent_stfu = false;
match peer_state.channel_by_id.entry(msg.channel_id) {
hash_map::Entry::Occupied(mut chan_entry) => {
if let Some(chan) = chan_entry.get_mut().as_funded_mut() {
@@ -10883,14 +10889,24 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
&self.logger, Some(*counterparty_node_id), Some(msg.channel_id), None
);
- if let Some(stfu) = try_channel_entry!(
- self, peer_state, chan.stfu(&msg, &&logger), chan_entry
- ) {
- sent_stfu = true;
- peer_state.pending_msg_events.push(MessageSendEvent::SendStfu {
- node_id: *counterparty_node_id,
- msg: stfu,
- });
+ let res = chan.stfu(&msg, &&logger);
+ let resp = try_channel_entry!(self, peer_state, res, chan_entry);
+ match resp {
+ None => Ok(false),
+ Some(StfuResponse::Stfu(msg)) => {
+ peer_state.pending_msg_events.push(MessageSendEvent::SendStfu {
+ node_id: *counterparty_node_id,
+ msg,
+ });
+ Ok(true)
+ },
+ Some(StfuResponse::SpliceInit(msg)) => {
+ peer_state.pending_msg_events.push(MessageSendEvent::SendSpliceInit {
+ node_id: *counterparty_node_id,
+ msg,
+ });
+ Ok(true)
+ },
}
} else {
let msg = "Peer sent `stfu` for an unfunded channel";
@@ -10905,8 +10921,6 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
msg.channel_id
))
}
-
- Ok(sent_stfu)
}
#[rustfmt::skip]
@@ -13873,8 +13887,8 @@ where
let persist = match &res {
Err(e) if e.closes_channel() => NotifyOption::DoPersist,
Err(_) => NotifyOption::SkipPersistHandleEvents,
- Ok(sent_stfu) => {
- if *sent_stfu {
+ Ok(responded) => {
+ if *responded {
NotifyOption::SkipPersistHandleEvents
} else {
NotifyOption::SkipPersistNoEvents
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index e42c338..a87a3cb 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -104,6 +104,12 @@ pub struct FundingTxInput {
pub(super) prevtx: Transaction,
}
+impl_writeable_tlv_based!(FundingTxInput, {
+ (1, utxo, required),
+ (3, sequence, required),
+ (5, prevtx, required),
+});
+
impl FundingTxInput {
fn new<F: FnOnce(&bitcoin::Script) -> bool>(
prevtx: Transaction, vout: u32, witness_weight: Weight, script_filter: F,
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 2445a2d..b60903d 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -28,6 +28,8 @@ fn test_v1_splice_in() {
let acceptor_node_index = 1;
let initiator_node = &nodes[initiator_node_index];
let acceptor_node = &nodes[acceptor_node_index];
+ let initiator_node_id = initiator_node.node.get_our_node_id();
+ let acceptor_node_id = acceptor_node.node.get_our_node_id();
let channel_value_sat = 100_000;
let channel_reserve_amnt_sat = 1_000;
@@ -87,12 +89,16 @@ fn test_v1_splice_in() {
None, // locktime
)
.unwrap();
+
+ let init_stfu = get_event_msg!(initiator_node, MessageSendEvent::SendStfu, acceptor_node_id);
+ acceptor_node.node.handle_stfu(initiator_node_id, &init_stfu);
+
+ let ack_stfu = get_event_msg!(acceptor_node, MessageSendEvent::SendStfu, initiator_node_id);
+ initiator_node.node.handle_stfu(acceptor_node_id, &ack_stfu);
+
// Extract the splice_init message
- let splice_init_msg = get_event_msg!(
- initiator_node,
- MessageSendEvent::SendSpliceInit,
- acceptor_node.node.get_our_node_id()
- );
+ let splice_init_msg =
+ get_event_msg!(initiator_node, MessageSendEvent::SendSpliceInit, acceptor_node_id);
assert_eq!(splice_init_msg.funding_contribution_satoshis, splice_in_sats as i64);
assert_eq!(splice_init_msg.funding_feerate_per_kw, funding_feerate_per_kw);
assert_eq!(splice_init_msg.funding_pubkey.to_string(), expected_initiator_funding_key);
diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs
index b2521d9..15fd7fe 100644
--- a/lightning/src/util/ser.rs
+++ b/lightning/src/util/ser.rs
@@ -26,7 +26,7 @@ use core::ops::Deref;
use alloc::collections::BTreeMap;
use bitcoin::absolute::LockTime as AbsoluteLockTime;
-use bitcoin::amount::Amount;
+use bitcoin::amount::{Amount, SignedAmount};
use bitcoin::consensus::Encodable;
use bitcoin::constants::ChainHash;
use bitcoin::hash_types::{BlockHash, Txid};
@@ -41,7 +41,7 @@ use bitcoin::secp256k1::ecdsa;
use bitcoin::secp256k1::schnorr;
use bitcoin::secp256k1::{PublicKey, SecretKey};
use bitcoin::transaction::{OutPoint, Transaction, TxOut};
-use bitcoin::{consensus, TxIn, Weight, Witness};
+use bitcoin::{consensus, Sequence, TxIn, Weight, Witness};
use dnssec_prover::rr::Name;
@@ -1383,6 +1383,19 @@ impl Readable for Amount {
}
}
+impl Writeable for SignedAmount {
+ fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
+ self.to_sat().write(w)
+ }
+}
+
+impl Readable for SignedAmount {
+ fn read<R: Read>(r: &mut R) -> Result<Self, DecodeError> {
+ let amount: i64 = Readable::read(r)?;
+ Ok(SignedAmount::from_sat(amount))
+ }
+}
+
impl Writeable for Weight {
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
self.to_wu().write(w)
@@ -1487,6 +1500,7 @@ impl_consensus_ser!(Transaction);
impl_consensus_ser!(TxIn);
impl_consensus_ser!(TxOut);
impl_consensus_ser!(Witness);
+impl_consensus_ser!(Sequence);
impl<T: Readable> Readable for Mutex<T> {
fn read<R: Read>(r: &mut R) -> Result<Self, DecodeError> {
Why this scored 26/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.