Write SpliceNegotiationFailed contributions for 0.2
What changed, and why it matters
This commit fixes a backward-compatibility bug in the Lightning Dev Kit (LDK) when a user downgrades from a newer version to version 0.2 while a splice negotiation is in progress. Without the fix, the newer version would generate an event telling the wallet to discard freed UTXOs, but version 0.2 could not understand that event, so those coins would appear stuck or lost to the downgraded node. The patch makes the newer version also write the freed UTXOs in the older event format that 0.2 understands, so the downgraded node surfaces the same information. It is a compatibility/data-loss fix, not a remote-exploitable vulnerability.
Reviewers should confirm that TLV type reuse (11/13) does not collide dangerously with other pending serialization paths, that the script-pubkey vs TxOut mapping is applied consistently everywhere FundingInfo::Contribution is built, and that the v0.3.0-beta1 forward-compatibility break is acceptable and documented. No immediate security patch is required; this is a compatibility correctness fix.
Security signals we found
Backward-compatibility data-loss fix: freed UTXOs from abandoned splice negotiation were not surfaced to downgraded 0.2 nodes
Serialization format change for Event::SpliceNegotiationFailed (event type 52) reuses TLV types 11 and 13 that 0.2 expects
New FailedSpliceContribution struct separates released UTXOs from retryable FundingContribution
FundingContribution::into_unique_contributions now returns Vec<TxOut> instead of Vec<ScriptBuf>, requiring callers to extract script pubkeys
ChannelMonitor now maps contributed outputs to script pubkeys when constructing FundingInfo::Contribution
Adds downgrade test `downgrade_mid_splice_negotiation_to_0_2` and unit tests for 0.2 deserialization and round-trip
Explicitly breaks readability for v0.3.0-beta1 ChannelManagers with pending splice failure events
Evidence from the diff
The change completes issue #4919 by ensuring that, when serializing Event::SpliceNegotiationFailed, the inputs and outputs released by a failed splice negotiation are also written at TLV types 11 and 13 (the same types LDK 0.2 used for Event::SpliceFailed::contributed_inputs and contributed_outputs). A new FailedSpliceContribution struct bundles the released UTXOs with the FundingContribution available for retry. On read, those fields are reassembled so round-trips preserve them. The patch also adjusts FundingContribution::into_unique_contributions to return full TxOuts instead of only script pubkeys, and updates channel.rs to map to script pubkeys where FundingInfo::Contribution requires them. A downgrade test verifies that a node serialized mid-negotiation reloads in 0.2 as SpliceFailed with the expected contributed_inputs and contributed_outputs. The commit notes this breaks forward readability for v0.3.0-beta1, which used TLV types 11/13 for other fields.
Changed components
lightning/src/events/mod.rs (Event serialization/deserialization and FailedSpliceContribution)lightning/src/ln/channel.rs (SpliceFundingFailed::into_parts, unique contribution handling)lightning/src/ln/funding.rs (FundingContribution::into_unique_contributions, test helper)lightning/src/chain/channelmonitor.rs (DiscardFunding construction)lightning/src/ln/functional_test_utils.rs (event assertions)lightning/src/ln/splicing_tests.rs (event assertions)lightning-tests/src/upgrade_downgrade_tests.rs (new downgrade test)pending_changelog/4919-splice-negotiation-failed-tlv-compat.txtInspect captured patch +403 / −64
### lightning-tests/src/upgrade_downgrade_tests.rs
@@ -1164,6 +1164,103 @@ fn upgrade_mid_splice_negotiation_from_0_2() {
send_payment(&nodes[0], &[&nodes[1]], 100_000);
}
+#[test]
+fn downgrade_mid_splice_negotiation_to_0_2() {
+ // An incomplete splice negotiation does not persist, so a ChannelManager written
+ // mid-negotiation embeds synthesized `SpliceNegotiationFailed` and `DiscardFunding` events.
+ // 0.2 must skip the `DiscardFunding` -- written under an odd event type since it carries
+ // `FundingInfo::Contribution`, which 0.2 cannot read -- and surface the failure as
+ // `SpliceFailed` with the contributed inputs and outputs at the TLV types it natively wrote
+ // them (see #4919).
+ let (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser, channel_id, 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 node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+ channel_id = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0).2;
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let splice_out_output = TxOut {
+ value: Amount::from_sat(1_000),
+ script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
+ };
+ contribution = do_initiate_splice_in_and_out(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ added_value,
+ vec![splice_out_output],
+ );
+
+ // Stop the negotiation after `splice_ack`, leaving both nodes in a state that isn't
+ // persisted.
+ let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu);
+ let stfu = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu);
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+ let _ = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0);
+
+ node_0_ser = nodes[0].node.encode();
+ node_1_ser = nodes[1].node.encode();
+ mon_0_ser = get_monitor!(nodes[0], channel_id).encode();
+ mon_1_ser = get_monitor!(nodes[1], channel_id).encode();
+ }
+
+ let mut chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2);
+ chanmon_cfgs[0].keys_manager.disable_all_state_policy_checks = true;
+ chanmon_cfgs[1].keys_manager.disable_all_state_policy_checks = true;
+ let node_cfgs = lightning_0_2_utils::create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = lightning_0_2_utils::create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = lightning_0_2_utils::create_network(2, &node_cfgs, &node_chanmgrs);
+ let mut config = lightning_0_2_utils::test_default_channel_config();
+ // The current side uses the anchors channel type by default; 0.2 only accepts a channel whose
+ // type it advertises support for.
+ config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true;
+
+ // The initiator's aborted negotiation surfaces as `SpliceFailed` carrying the freed inputs
+ // and outputs; the `DiscardFunding` with `FundingInfo::Contribution` is skipped.
+ let mgr_0 = lightning_0_2_utils::_reload_node(
+ &nodes[0],
+ config.clone(),
+ &node_0_ser,
+ &[&mon_0_ser[..]],
+ );
+ assert_eq!(mgr_0.list_channels().len(), 1);
+ let expected_inputs: Vec<bitcoin::OutPoint> =
+ contribution.inputs().iter().map(|utxo| utxo.outpoint()).collect();
+ let mut expected_outputs = contribution.outputs().to_vec();
+ expected_outputs.extend(contribution.change_output().cloned());
+ assert!(!expected_inputs.is_empty());
+ assert!(!expected_outputs.is_empty());
+ let events = mgr_0.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match &events[0] {
+ Event_0_2::SpliceFailed {
+ channel_id: chan_id,
+ contributed_inputs,
+ contributed_outputs,
+ ..
+ } => {
+ assert_eq!(chan_id.0, channel_id.0);
+ assert_eq!(*contributed_inputs, expected_inputs);
+ assert_eq!(*contributed_outputs, expected_outputs);
+ },
+ ev => panic!("Expected SpliceFailed, got {ev:?}"),
+ }
+
+ // The acceptor did not contribute, so it has no splice state to lose.
+ let mgr_1 =
+ lightning_0_2_utils::_reload_node(&nodes[1], config, &node_1_ser, &[&mon_1_ser[..]]);
+ assert_eq!(mgr_1.list_channels().len(), 1);
+ assert!(mgr_1.get_and_clear_pending_events().is_empty());
+}
+
#[test]
fn splice_inherited_across_0_2_checks_funding_transaction_for_overlap() {
// Negotiate a contributory splice on current, downgrade to LDK 0.2, then upgrade back. LDK 0.2
### lightning/src/chain/channelmonitor.rs
@@ -4110,6 +4110,7 @@ impl<Signer: EcdsaChannelSigner> ChannelMonitorImpl<Signer> {
self.funding.contributed_inputs(),
self.funding.contributed_outputs(),
) {
+ let outputs = outputs.into_iter().map(|output| output.script_pubkey).collect();
self.pending_events.push(Event::DiscardFunding {
channel_id: self.channel_id,
funding_info: FundingInfo::Contribution { inputs, outputs },
### lightning/src/events/mod.rs
@@ -43,7 +43,7 @@ use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret};
use crate::types::string::UntrustedString;
use crate::util::errors::APIError;
use crate::util::ser::{
- BigSize, FixedLengthReader, MaybeReadable, Readable, ReadableArgs, RequiredWrapper,
+ BigSize, FixedLengthReader, Iterable, MaybeReadable, Readable, ReadableArgs, RequiredWrapper,
UpgradableRequired, WithoutLength, Writeable, Writer,
};
@@ -53,7 +53,7 @@ use bitcoin::hashes::sha256::Hash as Sha256;
use bitcoin::hashes::Hash;
use bitcoin::script::ScriptBuf;
use bitcoin::secp256k1::PublicKey;
-use bitcoin::{OutPoint, Transaction};
+use bitcoin::{OutPoint, Transaction, TxOut};
use core::ops::Deref;
#[allow(unused_imports)]
@@ -101,6 +101,64 @@ impl_ser_tlv_based_enum!(FundingInfo,
}
);
+/// The funding contribution from a failed splice negotiation round, see
+/// [`Event::SpliceNegotiationFailed`].
+///
+/// This has no serialization of its own; it is written and read as part of the event.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FailedSpliceContribution {
+ /// UTXOs spent as inputs contributed to the failed round that were released by the failure,
+ /// i.e., excluding any still committed to an existing splice attempt.
+ contributed_inputs: Vec<OutPoint>,
+ /// Outputs contributed to the failed round that were released by the failure.
+ contributed_outputs: Vec<TxOut>,
+ /// The full contribution from the failed round.
+ contribution: FundingContribution,
+}
+
+impl FailedSpliceContribution {
+ pub(crate) fn new(
+ contributed_inputs: Vec<OutPoint>, contributed_outputs: Vec<TxOut>,
+ contribution: FundingContribution,
+ ) -> Self {
+ Self { contributed_inputs, contributed_outputs, contribution }
+ }
+
+ /// The funding contribution from the failed negotiation round. This can be fed back to
+ /// [`ChannelManager::funding_contributed`] to retry with the same parameters. Alternatively,
+ /// call [`ChannelManager::splice_channel`] to obtain a fresh [`FundingTemplate`] and build a
+ /// new contribution.
+ ///
+ /// The contribution preserves the full set of inputs and outputs from the failed round,
+ /// including any that were also committed to an existing splice attempt (a prior negotiated
+ /// candidate, a round still under negotiation, or a splice that just locked). Those
+ /// overlapping inputs and outputs are intentionally omitted from the preceding
+ /// [`Event::DiscardFunding`], since they remain committed to that other splice.
+ ///
+ /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
+ /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
+ /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate
+ pub fn contribution(&self) -> &FundingContribution {
+ &self.contribution
+ }
+
+ /// Consumes this, returning the funding contribution from the failed negotiation round, see
+ /// [`Self::contribution`].
+ pub fn into_contribution(self) -> FundingContribution {
+ self.contribution
+ }
+
+ #[cfg(any(test, ldk_bench, feature = "_test_utils"))]
+ pub(crate) fn contributed_inputs(&self) -> &[OutPoint] {
+ &self.contributed_inputs
+ }
+
+ #[cfg(any(test, ldk_bench, feature = "_test_utils"))]
+ pub(crate) fn contributed_outputs(&self) -> &[TxOut] {
+ &self.contributed_outputs
+ }
+}
+
/// The reason a funding negotiation round failed.
///
/// Each negotiation attempt (initial or RBF) resolves to either success or failure. This enum
@@ -1696,8 +1754,8 @@ pub enum Event {
/// [`Event::SpliceNegotiated`] is emitted if the negotiated transaction includes local
/// inputs or outputs. Prior successfully negotiated splice transactions are unaffected.
///
- /// Any UTXOs contributed to the failed round that are not committed to a prior negotiated
- /// splice transaction will be returned via a preceding [`Event::DiscardFunding`].
+ /// Any UTXOs contributed to the failed round that are not committed to an existing splice
+ /// attempt will be returned via a preceding [`Event::DiscardFunding`].
///
/// # Failure Behavior and Persistence
/// This event will eventually be replayed after failures-to-handle (i.e., the event handler
@@ -1715,21 +1773,10 @@ pub enum Event {
counterparty_node_id: PublicKey,
/// The reason the splice negotiation failed.
reason: NegotiationFailureReason,
- /// The funding contribution from the failed negotiation round, if available. This can be
- /// fed back to [`ChannelManager::funding_contributed`] to retry with the same parameters.
- /// Alternatively, call [`ChannelManager::splice_channel`] to obtain a fresh
- /// [`FundingTemplate`] and build a new contribution.
- ///
- /// The contribution preserves the full set of inputs and outputs from the failed round,
- /// including any that were also committed to a prior negotiated (but not yet locked)
- /// splice transaction. Those overlapping inputs and outputs are intentionally omitted
- /// from the preceding [`Event::DiscardFunding`], since they remain committed to that
- /// prior splice.
- ///
- /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed
- /// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel
- /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate
- contribution: Option<FundingContribution>,
+ /// The funding contribution from the failed negotiation round, if available. See
+ /// [`FailedSpliceContribution::contribution`] for how it can be reused in a subsequent
+ /// splice attempt.
+ contribution: Option<FailedSpliceContribution>,
},
/// Used to indicate to the user that they can abandon the funding transaction and recycle the
/// inputs for another purpose.
@@ -2563,14 +2610,28 @@ impl Writeable for Event {
ref contribution,
} => {
52u8.write(writer)?;
- // Types 3, 9, 11, and 13 were `channel_type`, `abandoned_funding_txo`,
- // `contributed_inputs`, and `contributed_outputs` in 0.2 and must not be reused.
+ // 0.2 wrote `contributed_inputs` and `contributed_outputs` at types 11 and 13, so
+ // write them for its benefit when downgrading. They are also read back to survive
+ // re-serialization. Types 3 and 9 were `channel_type` and `abandoned_funding_txo`
+ // in 0.2 and must not be reused.
+ let contributed_inputs = contribution
+ .as_ref()
+ .filter(|contribution| !contribution.contributed_inputs.is_empty())
+ .map(|contribution| Iterable(contribution.contributed_inputs.iter()));
+ let contributed_outputs = contribution
+ .as_ref()
+ .filter(|contribution| !contribution.contributed_outputs.is_empty())
+ .map(|contribution| Iterable(contribution.contributed_outputs.iter()));
+ let funding_contribution =
+ contribution.as_ref().map(|contribution| &contribution.contribution);
write_tlv_fields!(writer, {
(1, channel_id, required),
(5, user_channel_id, required),
(7, counterparty_node_id, required),
+ (11, contributed_inputs, option),
+ (13, contributed_outputs, option),
(15, reason, required),
- (17, contribution, option),
+ (17, funding_contribution, option),
});
},
// Note that, going forward, all new events must only write data inside of
@@ -3221,14 +3282,25 @@ impl MaybeReadable for Event {
},
52u8 => {
let mut f = || {
+ // Types 11 and 13 were written by 0.2 with the same encoding. When type 17 is
+ // absent (an event written by 0.2), they are dropped along with the missing
+ // contribution. Types 3 and 9 were `channel_type` and `abandoned_funding_txo`
+ // in 0.2 and must not be reused.
_init_and_read_len_prefixed_tlv_fields!(reader, {
(1, channel_id, required),
(5, user_channel_id, required),
(7, counterparty_node_id, required),
+ (11, contributed_inputs, optional_vec),
+ (13, contributed_outputs, optional_vec),
(15, reason, upgradable_option),
(17, contribution, option),
});
+ let contribution = contribution.map(|contribution| FailedSpliceContribution {
+ contributed_inputs: contributed_inputs.unwrap_or(Vec::new()),
+ contributed_outputs: contributed_outputs.unwrap_or(Vec::new()),
+ contribution,
+ });
Ok(Some(Event::SpliceNegotiationFailed {
channel_id: channel_id.0.unwrap(),
user_channel_id: user_channel_id.0.unwrap(),
@@ -3276,6 +3348,39 @@ impl MaybeReadable for Event {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::util::wallet_utils::ConfirmedUtxo;
+
+ fn test_funding_contribution() -> (FundingContribution, Vec<OutPoint>, Vec<bitcoin::TxOut>) {
+ let input_prevtx = Transaction {
+ version: bitcoin::transaction::Version::TWO,
+ lock_time: bitcoin::absolute::LockTime::ZERO,
+ input: vec![],
+ output: vec![bitcoin::TxOut {
+ value: bitcoin::Amount::from_sat(50_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(
+ &bitcoin::WPubkeyHash::from_slice(&[6; 20]).unwrap(),
+ ),
+ }],
+ };
+ let expected_inputs = vec![OutPoint { txid: input_prevtx.compute_txid(), vout: 0 }];
+ let inputs = vec![ConfirmedUtxo::new_p2wpkh(input_prevtx, 0).unwrap()];
+ let outputs = vec![bitcoin::TxOut {
+ value: bitcoin::Amount::from_sat(10_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(
+ &bitcoin::WPubkeyHash::from_slice(&[7; 20]).unwrap(),
+ ),
+ }];
+ let change_output = bitcoin::TxOut {
+ value: bitcoin::Amount::from_sat(30_000),
+ script_pubkey: ScriptBuf::new_p2wpkh(
+ &bitcoin::WPubkeyHash::from_slice(&[8; 20]).unwrap(),
+ ),
+ };
+ let mut expected_outputs = outputs.clone();
+ expected_outputs.push(change_output.clone());
+ let contribution = FundingContribution::new_for_test(inputs, outputs, Some(change_output));
+ (contribution, expected_inputs, expected_outputs)
+ }
#[test]
fn legacy_payment_forwarded_preserves_unknown_inbound_htlc_amount() {
@@ -3368,6 +3473,48 @@ mod tests {
#[test]
fn splice_negotiation_failed_event_read_by_0_2() -> Result<(), msgs::DecodeError> {
+ do_splice_negotiation_failed_event_read_by_0_2(None, Vec::new(), Vec::new())
+ }
+
+ #[test]
+ fn splice_negotiation_failed_event_contribution_read_by_0_2() -> Result<(), msgs::DecodeError> {
+ let (contribution, expected_inputs, expected_outputs) = test_funding_contribution();
+ let contribution = FailedSpliceContribution {
+ contributed_inputs: expected_inputs.clone(),
+ contributed_outputs: expected_outputs.clone(),
+ contribution,
+ };
+ do_splice_negotiation_failed_event_read_by_0_2(
+ Some(contribution),
+ expected_inputs,
+ expected_outputs,
+ )
+ }
+
+ #[test]
+ fn splice_negotiation_failed_event_overlapping_contribution_read_by_0_2(
+ ) -> Result<(), msgs::DecodeError> {
+ // Only the inputs and outputs released by the failure are written at types 11 and 13, not
+ // the contribution's full sets, which may include some still committed to an existing
+ // splice attempt.
+ let (contribution, _, outputs) = test_funding_contribution();
+ let expected_outputs = vec![outputs[0].clone()];
+ let contribution = FailedSpliceContribution {
+ contributed_inputs: Vec::new(),
+ contributed_outputs: expected_outputs.clone(),
+ contribution,
+ };
+ do_splice_negotiation_failed_event_read_by_0_2(
+ Some(contribution),
+ Vec::new(),
+ expected_outputs,
+ )
+ }
+
+ fn do_splice_negotiation_failed_event_read_by_0_2(
+ contribution: Option<FailedSpliceContribution>, expected_inputs: Vec<OutPoint>,
+ expected_outputs: Vec<bitcoin::TxOut>,
+ ) -> Result<(), msgs::DecodeError> {
let expected_channel_id = ChannelId::from_bytes([2; 32]);
let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[42; 32]).unwrap();
@@ -3378,7 +3525,7 @@ mod tests {
user_channel_id: 786,
counterparty_node_id: expected_node_id,
reason: NegotiationFailureReason::PeerDisconnected,
- contribution: None,
+ contribution,
};
let encoded = event.encode();
let mut cursor = &encoded[..];
@@ -3409,9 +3556,9 @@ mod tests {
let abandoned_funding_txo: Option<OutPoint> = abandoned_funding_txo;
assert_eq!(abandoned_funding_txo, None);
let contributed_inputs: Option<Vec<OutPoint>> = contributed_inputs;
- assert!(contributed_inputs.unwrap().is_empty());
+ assert_eq!(contributed_inputs.unwrap(), expected_inputs);
let contributed_outputs: Option<Vec<bitcoin::TxOut>> = contributed_outputs;
- assert!(contributed_outputs.unwrap().is_empty());
+ assert_eq!(contributed_outputs.unwrap(), expected_outputs);
Ok(())
}
@@ -3420,18 +3567,33 @@ mod tests {
fn splice_negotiation_failed_event_round_trip() {
let secp_ctx = bitcoin::secp256k1::Secp256k1::new();
let secret_key = bitcoin::secp256k1::SecretKey::from_slice(&[42; 32]).unwrap();
- let event = Event::SpliceNegotiationFailed {
- channel_id: ChannelId::from_bytes([2; 32]),
- user_channel_id: 786,
- counterparty_node_id: PublicKey::from_secret_key(&secp_ctx, &secret_key),
- reason: NegotiationFailureReason::CounterpartyAborted {
- msg: UntrustedString("hodl".to_owned()),
- },
- contribution: None,
+ let (contribution, inputs, outputs) = test_funding_contribution();
+ let full_contribution = FailedSpliceContribution {
+ contributed_inputs: inputs,
+ contributed_outputs: outputs.clone(),
+ contribution: contribution.clone(),
};
- let encoded = event.encode();
- let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
- assert_eq!(event, decoded);
+ // The contributed inputs and outputs must round trip independently of the contribution's
+ // own sets since some may still be committed to an existing splice attempt.
+ let overlapping_contribution = FailedSpliceContribution {
+ contributed_inputs: Vec::new(),
+ contributed_outputs: vec![outputs[0].clone()],
+ contribution,
+ };
+ for contribution in [None, Some(full_contribution), Some(overlapping_contribution)] {
+ let event = Event::SpliceNegotiationFailed {
+ channel_id: ChannelId::from_bytes([2; 32]),
+ user_channel_id: 786,
+ counterparty_node_id: PublicKey::from_secret_key(&secp_ctx, &secret_key),
+ reason: NegotiationFailureReason::CounterpartyAborted {
+ msg: UntrustedString("hodl".to_owned()),
+ },
+ contribution,
+ };
+ let encoded = event.encode();
+ let decoded = Event::read(&mut &encoded[..]).unwrap().unwrap();
+ assert_eq!(event, decoded);
+ }
}
#[test]
### lightning/src/ln/channel.rs
@@ -38,7 +38,9 @@ use crate::chain::channelmonitor::{
use crate::chain::package::verify_channel_type_features;
use crate::chain::transaction::{OutPoint, TransactionData};
use crate::chain::BlockLocator;
-use crate::events::{ClosureReason, FundingInfo, NegotiationFailureReason};
+use crate::events::{
+ ClosureReason, FailedSpliceContribution, FundingInfo, NegotiationFailureReason,
+};
use crate::ln::chan_utils;
use crate::ln::chan_utils::{
get_commitment_transaction_number_obscure_factor, max_htlcs, second_stage_tx_fees_sat,
@@ -3461,10 +3463,14 @@ impl PendingFunding {
existing_outputs: impl Iterator<Item = &'a bitcoin::Script>,
) -> Option<(Vec<bitcoin::OutPoint>, Vec<ScriptBuf>)> {
let funding_components = self.funding_components();
- contribution.into_unique_contributions(
- existing_inputs.chain(funding_components.inputs()),
- existing_outputs.chain(funding_components.outputs()),
- )
+ contribution
+ .into_unique_contributions(
+ existing_inputs.chain(funding_components.inputs()),
+ existing_outputs.chain(funding_components.outputs()),
+ )
+ .map(|(inputs, outputs)| {
+ (inputs, outputs.into_iter().map(|output| output.script_pubkey).collect())
+ })
}
/// Our most recent contribution across rounds, including any round still under negotiation.
@@ -7510,7 +7516,7 @@ pub struct SpliceFundingFailed {
/// Outputs contributed to the splice transaction. Excludes outputs already contributed
/// in prior rounds, which may be included in `contribution`.
- contributed_outputs: Vec<ScriptBuf>,
+ contributed_outputs: Vec<TxOut>,
/// The funding contribution from the failed round.
contribution: FundingContribution,
@@ -7534,17 +7540,26 @@ impl SpliceFundingFailed {
/// Splits into the funding info for `DiscardFunding` (if there are inputs or outputs to
/// discard) and the contribution for `SpliceNegotiationFailed`.
- pub(super) fn into_parts(self) -> (Option<FundingInfo>, FundingContribution) {
+ pub(super) fn into_parts(self) -> (Option<FundingInfo>, FailedSpliceContribution) {
let funding_info =
if !self.contributed_inputs.is_empty() || !self.contributed_outputs.is_empty() {
Some(FundingInfo::Contribution {
- inputs: self.contributed_inputs,
- outputs: self.contributed_outputs,
+ inputs: self.contributed_inputs.clone(),
+ outputs: self
+ .contributed_outputs
+ .iter()
+ .map(|output| output.script_pubkey.clone())
+ .collect(),
})
} else {
None
};
- (funding_info, self.contribution)
+ let contribution = FailedSpliceContribution::new(
+ self.contributed_inputs,
+ self.contributed_outputs,
+ self.contribution,
+ );
+ (funding_info, contribution)
}
}
@@ -12378,7 +12393,10 @@ where
),
)
})
- .map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs })
+ .map(|(inputs, outputs)| FundingInfo::Contribution {
+ inputs,
+ outputs: outputs.into_iter().map(|output| output.script_pubkey).collect(),
+ })
.collect::<Vec<_>>()
};
@@ -13368,10 +13386,17 @@ where
existing.contributed_inputs(),
existing.contributed_outputs(),
),
- None => contribution.into_unique_contributions(
- existing.contributed_inputs(),
- existing.contributed_outputs(),
- ),
+ None => contribution
+ .into_unique_contributions(
+ existing.contributed_inputs(),
+ existing.contributed_outputs(),
+ )
+ .map(|(inputs, outputs)| {
+ (
+ inputs,
+ outputs.into_iter().map(|output| output.script_pubkey).collect(),
+ )
+ }),
};
return match unique_contributions {
None => Err(QuiescentError::DoNothing),
### lightning/src/ln/functional_test_utils.rs
@@ -3306,7 +3306,16 @@ pub fn expect_failed_rbf_events<'a, 'b, 'c>(
Event::SpliceNegotiationFailed { channel_id, reason, contribution, .. } => {
assert_eq!(channel_id, expected_channel_id);
assert_eq!(*reason, expected_reason);
- assert_eq!(contribution.as_ref(), Some(expected_contribution));
+ let contribution = contribution.as_ref().unwrap();
+ assert_eq!(contribution.contribution(), expected_contribution);
+ // The inputs and outputs released by the failure must match those discarded.
+ assert_eq!(contribution.contributed_inputs(), &discarded.0[..]);
+ let contributed_output_scripts = contribution
+ .contributed_outputs()
+ .iter()
+ .map(|output| output.script_pubkey.clone())
+ .collect::<Vec<_>>();
+ assert_eq!(contributed_output_scripts, discarded.1);
},
other => panic!("Expected SpliceNegotiationFailed, got {other:?}"),
}
### lightning/src/ln/funding.rs
@@ -724,6 +724,24 @@ impl FundingContribution {
self.max_feerate
}
+ #[cfg(test)]
+ pub(crate) fn new_for_test(
+ inputs: Vec<ConfirmedUtxo>, outputs: Vec<TxOut>, change_output: Option<TxOut>,
+ ) -> Self {
+ FundingContribution {
+ estimated_fee: Amount::ZERO,
+ inputs,
+ outputs,
+ change_output,
+ feerate: FeeRate::from_sat_per_kwu(
+ crate::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW as u64,
+ ),
+ max_feerate: FeeRate::MAX,
+ is_splice: true,
+ input_mode: Some(FundingInputMode::CoinSelected),
+ }
+ }
+
/// Tries to satisfy a new request using only this contribution's existing inputs.
///
/// For input-backed contributions, this reuses the current inputs, adjusts the explicit
@@ -857,7 +875,7 @@ impl FundingContribution {
pub(crate) fn into_unique_contributions<'a>(
self, existing_inputs: impl Iterator<Item = OutPoint>,
existing_outputs: impl Iterator<Item = &'a bitcoin::Script>,
- ) -> Option<(Vec<OutPoint>, Vec<ScriptBuf>)> {
+ ) -> Option<(Vec<OutPoint>, Vec<TxOut>)> {
let FundingContribution { mut inputs, mut outputs, mut change_output, .. } = self;
for existing in existing_inputs {
inputs.retain(|input| input.outpoint() != existing);
@@ -877,11 +895,7 @@ impl FundingContribution {
None
} else {
let inputs = inputs.into_iter().map(|input| input.outpoint()).collect();
- let outputs = outputs
- .into_iter()
- .chain(change_output.into_iter())
- .map(|output| output.script_pubkey)
- .collect();
+ let outputs = outputs.into_iter().chain(change_output.into_iter()).collect();
Some((inputs, outputs))
}
}
### lightning/src/ln/splicing_tests.rs
@@ -935,7 +935,10 @@ fn complete_splice_locked_exchange<'a, 'b, 'c, 'd>(
let expected_contribution =
expected_failed_rbf[idx].expect("Unexpected SpliceNegotiationFailed event");
assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf);
- assert_eq!(actual_contribution.as_ref(), Some(expected_contribution));
+ let actual_contribution = actual_contribution
+ .as_ref()
+ .map(|contribution| contribution.contribution());
+ assert_eq!(actual_contribution, Some(expected_contribution));
assert!(!saw_failed_rbf, "Duplicate SpliceNegotiationFailed event");
saw_failed_rbf = true;
},
@@ -10161,7 +10164,8 @@ fn test_discarded_rbf_reports_feerate_too_low() {
assert_eq!(outputs, std::slice::from_ref(&script_pubkey));
assert_eq!(*failed_channel_id, channel_id);
assert_eq!(*reason, NegotiationFailureReason::FeeRateTooLow);
- assert_eq!(contribution.as_ref(), Some(&stale_contribution));
+ let contribution = contribution.as_ref().map(|contribution| contribution.contribution());
+ assert_eq!(contribution, Some(&stale_contribution));
}
assert_no_queued_splice(&nodes[0], &channel_id);
@@ -10785,6 +10789,8 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() {
} => {
assert_eq!(cid, channel_id);
assert_eq!(reason, NegotiationFailureReason::FeeRateTooLow);
+ let failed_contribution =
+ failed_contribution.map(|contribution| contribution.into_contribution());
assert_eq!(failed_contribution, Some(contribution.clone()));
},
other => panic!("Expected SpliceNegotiationFailed, got {other:?}"),
@@ -12761,7 +12767,9 @@ fn test_queued_rbf_fails_when_chain_event_promotes_splice() {
}
match &events[2] {
Event::SpliceNegotiationFailed { contribution: failed_contribution, reason, .. } => {
- assert_eq!(failed_contribution.as_ref(), Some(&contribution));
+ let failed_contribution =
+ failed_contribution.as_ref().map(|contribution| contribution.contribution());
+ assert_eq!(failed_contribution, Some(&contribution));
assert_eq!(*reason, NegotiationFailureReason::CannotInitiateRbf);
},
other => panic!("Expected SpliceNegotiationFailed, got {other:?}"),
### pending_changelog/4919-splice-negotiation-failed-tlv-compat.txt
@@ -1,6 +1,29 @@
+# API Updates
+
+ * The `contribution` field of `Event::SpliceNegotiationFailed` is now an
+ `Option<FailedSpliceContribution>`, which exposes the `FundingContribution` for retry (#4919).
+
# Backwards Compatibility (0.3)
* A pending `Event::SpliceFailed` written by 0.2 is read as `Event::SpliceNegotiationFailed` with
- `reason` set to `NegotiationFailureReason::Unknown` and no `contribution` (#4919). Note that a
- pending `Event::DiscardFunding` with `FundingInfo::Contribution` still prevents downgrade to
- 0.2.
+ `reason` set to `NegotiationFailureReason::Unknown` and no `contribution` (#4919).
+
+# Forward Compatibility (0.3)
+
+ * When downgrading to 0.2 with a splice negotiation pending, the failure surfaces as
+ `Event::SpliceFailed` with the contributed inputs and outputs released by the failure set,
+ i.e., excluding any still committed to a prior negotiated splice transaction (#4919).
+
+ * A pending `Event::DiscardFunding` with `FundingInfo::Contribution` is not surfaced on 0.2
+ (#4919).
+
+# Note for the 0.3 changelog
+
+ * The 0.3 changelog entry drafted from `4388-splice-failed-discard-funding.txt` states that
+ downgrading will not set the removed `contributed_inputs`/`contributed_outputs` fields on
+ `SpliceNegotiationFailed`. That is no longer accurate once this change lands and should be
+ corrected on the 0.3 branch to match the forward compatibility notes above (#4919).
+
+ * The entry drafted from `4514-splice-negotiation-failed.txt` describes the `contribution`
+ field as returning the `FundingContribution`. It is now an `Option<FailedSpliceContribution>`
+ and should be corrected on the 0.3 branch to match the API updates above (#4919).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.