diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index d8291571884..78bfe16f588 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -227,6 +227,10 @@ impl ChainState { self.utxos.contains(outpoint) } + fn is_confirmed_txid(&self, txid: &Txid) -> bool { + self.confirmed_txids.contains(txid) + } + fn confirmed_output(&self, outpoint: &BitcoinOutPoint) -> Option<&TxOut> { if !self.confirmed_txids.contains(&outpoint.txid) { return None; @@ -936,7 +940,8 @@ fn assert_disconnect_action(action: &msgs::ErrorAction) -> (&msgs::WarningMessag // Since sending/receiving messages may be delayed, `timer_tick_occurred` may cause a node to // disconnect their counterparty if they're expecting a timely response. if let msgs::ErrorAction::DisconnectPeerWithWarning { ref msg } = action { - let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF"); + let is_quiescent_msg = msg.data.contains("already sent splice_locked, cannot RBF") + || msg.data.contains("contribution no longer valid at quiescence"); if !msg.data.contains("Disconnecting due to timeout awaiting response") && !is_quiescent_msg { panic!("Unexpected disconnect case: {}", msg.data); @@ -2220,6 +2225,33 @@ fn build_node_config(chan_type: ChanType) -> UserConfig { config } +fn assert_no_stale_splice_negotiation( + node: &HarnessNode<'_>, channel_id: &ChannelId, counterparty_node_id: &PublicKey, context: &str, +) { + let Some(channel) = node.list_channels().into_iter().find(|channel| { + channel.channel_id == *channel_id && channel.counterparty.node_id == *counterparty_node_id + }) else { + return; + }; + let Some(details) = channel.splice_details else { return }; + + assert!( + details.negotiation.is_none(), + "{} left active splice negotiation behind: {:?}", + context, + details + ); + assert!( + details.queued_contribution.is_some() + || !details.candidates.is_empty() + || details.confirmed_candidate.is_some() + || details.received_splice_locked_txid.is_some(), + "{} left empty splice details behind: {:?}", + context, + details + ); +} + fn assert_test_invariants(nodes: &[HarnessNode<'_>; 3]) { assert_eq!(nodes[0].list_channels().len(), 3); assert_eq!(nodes[1].list_channels().len(), 6); @@ -2851,7 +2883,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { fn process_msg_event( node_idx: usize, source_node_id: PublicKey, event: MessageSendEvent, corrupt_forward: bool, limit_events: ProcessMessages, nodes: &[HarnessNode<'_>; 3], - out: &Out, + chain_state: &ChainState, out: &Out, ) -> Option { match event { MessageSendEvent::UpdateHTLCs { node_id, channel_id, updates } => { @@ -2914,6 +2946,12 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { MessageSendEvent::SendTxAbort { ref node_id, ref msg } => { let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "tx_abort"); nodes[dest_idx].handle_tx_abort(source_node_id, msg); + assert_no_stale_splice_negotiation( + &nodes[dest_idx], + &msg.channel_id, + &source_node_id, + "tx_abort receive", + ); None }, MessageSendEvent::SendTxInitRbf { ref node_id, ref msg } => { @@ -2943,6 +2981,11 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { }, MessageSendEvent::SendSpliceLocked { ref node_id, ref msg } => { let dest_idx = log_peer_message(node_idx, node_id, nodes, out, "splice_locked"); + assert!( + chain_state.is_confirmed_txid(&msg.splice_txid), + "splice_locked referenced unconfirmed txid {}", + msg.splice_txid + ); nodes[dest_idx].handle_splice_locked(source_node_id, msg); None }, @@ -2974,6 +3017,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { } let nodes = &self.nodes; + let chain_state = &self.chain_state; let out = &self.out; let queues = &mut self.queues; let mut events = queues.take_for_node(node_idx); @@ -2994,6 +3038,7 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { corrupt_forward, limit_events, nodes, + chain_state, out, ); if limit_events != ProcessMessages::AllMessages { @@ -3050,7 +3095,15 @@ impl<'a, Out: Output + MaybeSend + MaybeSync> Harness<'a, Out> { events::Event::PaymentPathSuccessful { .. } => {}, events::Event::PaymentPathFailed { .. } => {}, events::Event::PaymentForwarded { .. } if node_idx == 1 => {}, - events::Event::ChannelReady { .. } => {}, + events::Event::ChannelReady { funding_txo, .. } => { + if let Some(funding_txo) = funding_txo { + assert!( + chain_state.is_confirmed_txid(&funding_txo.txid), + "ChannelReady referenced unconfirmed funding txid {}", + funding_txo.txid + ); + } + }, events::Event::HTLCHandlingFailed { .. } => {}, events::Event::FundingTransactionReadyForSigning { channel_id, diff --git a/fuzz/src/router.rs b/fuzz/src/router.rs index 2295ae3d7ff..aa3d274dac2 100644 --- a/fuzz/src/router.rs +++ b/fuzz/src/router.rs @@ -257,6 +257,7 @@ pub fn do_test(data: &[u8], out: Out) { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, }); } Some(&$first_hops_vec[..]) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 136ae919a97..dbb5372db0c 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -11,6 +11,7 @@ //! LDK. use lightning_0_2::commitment_signed_dance as commitment_signed_dance_0_2; +use lightning_0_2::events::bump_transaction::sync::WalletSourceSync as WalletSourceSync_0_2; use lightning_0_2::events::Event as Event_0_2; use lightning_0_2::get_monitor as get_monitor_0_2; use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2; @@ -60,6 +61,7 @@ use lightning::ln::splicing_tests::*; use lightning::ln::types::ChannelId; use lightning::onion_message::packet::Packet; use lightning::sign::OutputSpender; +use lightning::util::errors::APIError; use lightning::util::ser::{MaybeReadable, Writeable}; use lightning::util::wallet_utils::WalletSourceSync; @@ -819,3 +821,284 @@ fn test_onion_message_intercepted_scid_downgrade_to_0_2() { let result = ::read(&mut reader); assert!(result.is_err(), "LDK 0.2 should fail to decode a ShortChannelId variant"); } + +fn downgrade_setup_single_splice() -> (Vec, Vec, Vec, Vec) { + // Build a current node with a single pending (negotiated, not yet locked) splice that node 0 + // funded (so node 0 is contributory, node 1 is a non-contributory acceptor). Return both + // nodes' serialized ChannelManager + ChannelMonitor. + 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); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + let node_0_ser = nodes[0].node.encode(); + let node_1_ser = nodes[1].node.encode(); + let mon_0_ser = get_monitor!(nodes[0], channel_id).encode(); + let mon_1_ser = get_monitor!(nodes[1], channel_id).encode(); + (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser) +} + +#[test] +fn downgrade_single_splice_loads_on_0_2() { + // A current node with a single pending splice serializes in a form LDK 0.2 can still read, + // whether or not we funded it: only odd TLVs are written (the even RBF gate is omitted for a + // single round), so 0.2 skips the contribution it can't track and loads the channel. RBF is + // the only state that blocks downgrade (see downgrade_rbf_refused_by_0_2). + let (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser) = downgrade_setup_single_splice(); + + 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, so enable anchors here too (otherwise the read is refused on + // the channel type, before the splice serialization is ever exercised). + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + + // Node 0 (contributory initiator): the contribution lives in an odd TLV that 0.2 skips. + 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); + // Node 1 (non-contributory acceptor): nothing 0.2 can't represent. + 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); +} + +#[test] +#[should_panic(expected = "UnknownRequiredFeature")] +fn downgrade_rbf_refused_by_0_2() { + // RBF (more than one negotiation round) is the one splice state LDK 0.2 cannot operate. Current + // writes the even RBF-gate TLV for it, which 0.2 rejects as an unknown even (required) field, + // so reading the ChannelManager fails rather than silently mishandling the extra candidate. + let (node_0_ser, mon_0_ser); + { + 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 (_, _, 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, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + + // RBF the splice, producing a second negotiated candidate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate = bitcoin::FeeRate::from_sat_per_kwu(1000); + let rbf_contribution = + do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); + complete_rbf_handshake(&nodes[0], &nodes[1]); + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + rbf_contribution, + new_funding_script, + ); + let _ = sign_interactive_funding_tx( + &nodes[0], + &nodes[1], + false, + Some(first_splice_tx.compute_txid()), + ); + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + node_0_ser = nodes[0].node.encode(); + mon_0_ser = get_monitor!(nodes[0], 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(); + // Match the anchors channel type used on the current side, so the manager read reaches (and + // fails on) the even RBF-gate TLV rather than refusing the channel type itself. + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + // _reload_node unwraps the manager read, which fails on the even RBF-gate TLV → panic. + let _ = lightning_0_2_utils::_reload_node(&nodes[0], config, &node_0_ser, &[&mon_0_ser[..]]); +} + +#[test] +fn upgrade_single_splice_from_0_2() { + // A pending single splice written by LDK 0.2 — which never tracked our contribution — is read + // by current: the candidate comes back via the TLV-3 fallback with `contribution: None`. + let (node_0_ser, node_1_ser, mon_0_ser, mon_1_ser, chan_id_bytes); + { + let chanmon_cfgs = lightning_0_2_utils::create_chanmon_cfgs(2); + 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 channel_id = lightning_0_2_utils::create_announced_chan_between_nodes_with_value( + &nodes, 0, 1, 100_000, 0, + ) + .2; + chan_id_bytes = channel_id.0; + + let contribution = lightning_0_2::ln::funding::SpliceContribution::SpliceOut { + outputs: vec![bitcoin::TxOut { + value: bitcoin::Amount::from_sat(1_000), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }], + }; + // 0.2 drives the splice through tx_signatures, leaving one negotiated (unlocked) candidate. + let _ = lightning_0_2::ln::splicing_tests::splice_channel( + &nodes[0], + &nodes[1], + channel_id, + contribution, + ); + + node_0_ser = nodes[0].node.encode(); + node_1_ser = nodes[1].node.encode(); + mon_0_ser = get_monitor_0_2!(nodes[0], channel_id).encode(); + mon_1_ser = get_monitor_0_2!(nodes[1], channel_id).encode(); + } + + let mut chanmon_cfgs = 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 = create_node_cfgs(2, &chanmon_cfgs); + let (persister_a, persister_b, chain_mon_a, chain_mon_b); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let (node_a, node_b); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let config = test_default_channel_config(); + reload_node!( + nodes[0], + config.clone(), + &node_0_ser, + &[&mon_0_ser[..]], + persister_a, + chain_mon_a, + node_a + ); + reload_node!( + nodes[1], + config, + &node_1_ser, + &[&mon_1_ser[..]], + persister_b, + chain_mon_b, + node_b + ); + + // Current reads the 0.2 splice: one negotiated candidate, no contribution recorded. + let channel_id = ChannelId(chan_id_bytes); + for node in nodes.iter() { + let channels = node.node.list_channels(); + let details = channels.iter().find(|c| c.channel_id == channel_id).unwrap(); + let splice = details.splice_details.as_ref().expect("pending splice"); + assert_eq!(splice.candidates.len(), 1); + assert_eq!(splice.candidates[0].contribution, None); + } + + // The splice cannot be RBF'd: 0.2 persisted no feerate, so splice_channel refuses it. + let node_id_1 = nodes[1].node.get_our_node_id(); + let err = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap_err(); + let expected = format!( + "Channel {} has a pending splice from a prior LDK version and cannot be spliced again", + channel_id, + ); + match err { + APIError::APIMisuseError { err } => assert_eq!(err, expected), + _ => panic!("unexpected error: {:?}", err), + } +} + +#[test] +fn splice_inherited_across_0_2_cannot_be_rbfed() { + // Negotiate a contributory splice on current, downgrade to LDK 0.2, then upgrade back. LDK 0.2 + // persists neither our contribution nor the splice feerate and does not retain the odd TLVs that + // carry them, so the splice returns to current without either. `splice_channel` needs the prior + // feerate to derive the RBF floor, so it refuses such a channel with a clean error rather than + // operating on incomplete state (which would otherwise trip a debug assertion that a pending + // splice always has a known feerate). + let chan_id_bytes; + let (v3_mgr, v3_mon); + { + 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); + chan_id_bytes = channel_id.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); + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + + v3_mgr = nodes[0].node.encode(); + v3_mon = get_monitor!(nodes[0], channel_id).encode(); + } + + // Downgrade node 0 to LDK 0.2 and re-serialize there, stripping the contribution and feerate. + let (v2_mgr, v2_mon); + { + 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(); + config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = true; + let mgr = lightning_0_2_utils::_reload_node(&nodes[0], config, &v3_mgr, &[&v3_mon[..]]); + assert_eq!(mgr.list_channels().len(), 1); + let v2_channel_id = lightning_0_2::ln::types::ChannelId(chan_id_bytes); + v2_mgr = mgr.encode(); + v2_mon = get_monitor_0_2!(nodes[0], v2_channel_id).encode(); + } + + // Upgrade back to current and attempt to RBF the inherited splice. + let mut chanmon_cfgs = 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 = create_node_cfgs(2, &chanmon_cfgs); + let (persister, chain_mon, new_node); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let config = test_default_channel_config(); + reload_node!(nodes[0], config, &v2_mgr, &[&v2_mon[..]], persister, chain_mon, new_node); + + let channel_id = ChannelId(chan_id_bytes); + let node_id_1 = nodes[1].node.get_our_node_id(); + let err = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap_err(); + let expected = format!( + "Channel {} has a pending splice from a prior LDK version and cannot be spliced again", + channel_id, + ); + match err { + APIError::APIMisuseError { err } => assert_eq!(err, expected), + _ => panic!("unexpected error: {:?}", err), + } +} diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index ec0ad6ccd9b..2e56d35c887 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1647,8 +1647,12 @@ pub enum Event { /// [`ChainMonitor::get_claimable_balances`]: crate::chain::chainmonitor::ChainMonitor::get_claimable_balances last_local_balance_msat: Option, }, - /// Used to indicate that a splice for the given `channel_id` has been negotiated and its - /// funding transaction has been broadcast. + /// Used to indicate that a splice for the given `channel_id` has been negotiated, its + /// funding transaction has been broadcast, and local inputs or outputs were contributed to + /// it. + /// + /// This event is not emitted if the counterparty negotiated a splice without using a local + /// contribution. /// /// The splice is then considered pending until both parties have seen enough confirmations to /// consider the funding locked. Once this occurs, an [`Event::ChannelReady`] will be emitted. @@ -1679,9 +1683,9 @@ pub enum Event { }, /// Used to indicate that a splice negotiation round for the given `channel_id` has failed. /// - /// Each splice attempt (initial or RBF) resolves to either [`Event::SpliceNegotiated`] on - /// success or this event on failure. Prior successfully negotiated splice transactions are - /// unaffected. + /// Each splice attempt (initial or RBF) resolves to this event on failure. On success, + /// [`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`]. diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f36c19748f0..f60e63a87e9 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1853,7 +1853,7 @@ fn test_async_splice_initial_commit_sig() { acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); let _ = get_event!(initiator, Event::SpliceNegotiated); - let _ = get_event!(acceptor, Event::SpliceNegotiated); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } #[test] @@ -1945,7 +1945,7 @@ fn test_async_splice_initial_commit_sig_waits_for_monitor_before_tx_signatures() acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); let _ = get_event!(initiator, Event::SpliceNegotiated); - let _ = get_event!(acceptor, Event::SpliceNegotiated); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } #[test] @@ -2022,5 +2022,5 @@ fn test_async_splice_shared_input_signature_released_on_unblock() { acceptor.node.handle_tx_signatures(initiator_node_id, &tx_signatures); let _ = get_event!(initiator, Event::SpliceNegotiated); - let _ = get_event!(acceptor, Event::SpliceNegotiated); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index ab9c964e5cb..4a23cf8309d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -47,8 +47,9 @@ use crate::ln::chan_utils::{ EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ - ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, - OutboundHTLCDetails, OutboundHTLCStateDetails, + ChannelShutdownState, ConfirmedSpliceCandidate, CounterpartyForwardingInfo, InboundHTLCDetails, + InboundHTLCStateDetails, OutboundHTLCDetails, OutboundHTLCStateDetails, SpliceCandidateDetails, + SpliceDetails, SpliceNegotiationDetails, SpliceNegotiationStatus, }; use crate::ln::channelmanager::{ self, BlindedFailure, ChannelReadyOrder, FundingConfirmedMessage, HTLCFailureMsg, @@ -84,7 +85,7 @@ use crate::util::config::{ use crate::util::errors::APIError; use crate::util::logger::{Level as LoggerLevel, Logger, Record, WithContext}; use crate::util::scid_utils::{block_from_scid, scid_from_parts}; -use crate::util::ser::{Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; +use crate::util::ser::{Iterable, Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use crate::util::wallet_utils::{ConfirmedUtxo, Input}; use crate::{impl_readable_for_vec, impl_writeable_for_vec}; @@ -2964,9 +2965,20 @@ impl FundingScope { struct PendingFunding { funding_negotiation: Option, + /// Our contribution to the funding negotiation round currently in progress, if we are + /// contributing to it. Set when the round starts, moved into the [`NegotiatedCandidate`] + /// when negotiation completes, and dropped in + /// [`FundedChannel::reset_pending_splice_state`] if the round is abandoned. + /// + /// When the counterparty initiates an RBF and a prior round included our contribution, this + /// is set to that contribution adjusted to the new feerate (or the RBF is rejected if the + /// adjustment fails, in which case no round starts). This ensures a splice we contributed to + /// never loses our contribution in subsequent rounds. + negotiation_contribution: Option, + /// Funding candidates that have been negotiated but have not reached enough confirmations /// by both counterparties to have exchanged `splice_locked` and be promoted. - negotiated_candidates: Vec, + negotiated_candidates: Vec, /// The funding txid used in the `splice_locked` sent to the counterparty. sent_funding_txid: Option, @@ -2977,21 +2989,26 @@ struct PendingFunding { /// The feerate used in the last successfully negotiated funding transaction. /// Used for validating the minimum feerate increase rule on RBF attempts. last_funding_feerate_sat_per_1000_weight: Option, +} - /// The funding contributions from splice/RBF rounds where we contributed. - /// - /// A new entry is appended when we contribute to a negotiation round (either as initiator - /// or acceptor). Rounds where we don't contribute (e.g., counterparty-only splice) do not - /// add an entry. Once non-empty, every subsequent round appends: when the counterparty - /// initiates an RBF, the last entry is adjusted to the new feerate and appended as a new - /// entry (or the RBF is rejected if the adjustment fails, in which case no round starts). - /// - /// If the round aborts, the last entry is popped in - /// [`FundedChannel::reset_pending_splice_state`], restoring the prior round's contribution - /// as the most recent entry. - contributions: Vec, +/// A funding candidate that has been negotiated, together with our contribution, if any, to the +/// negotiation round that produced it. +#[derive(Debug)] +struct NegotiatedCandidate { + funding: FundingScope, + + /// Our contribution to the negotiation round that produced this candidate, or `None` if only + /// the counterparty contributed. Once a candidate includes our contribution, every later + /// candidate does as well: RBF rounds carry the contribution forward (possibly adjusted to a + /// new feerate) rather than dropping it, preserving the splice intention. + contribution: Option, } +impl_ser_tlv_based!(NegotiatedCandidate, { + (1, funding, required), + (3, contribution, option), +}); + #[derive(Debug)] enum FundingNegotiation { AwaitingAck { @@ -3050,20 +3067,47 @@ impl Writeable for PendingFundingWriteable<'_> { Some(FundingNegotiation::AwaitingSignatures { .. }) ) ); - let contributions_len = if self.reset_funding_negotiation - && self.pending_funding.funding_negotiation.is_some() - { - self.pending_funding.contributions.len().saturating_sub(1) - } else { - self.pending_funding.contributions.len() - }; + // The in-flight round's contribution is only written if its negotiation survives + // serialization round trips. It goes in an odd TLV that LDK 0.2 skips (0.2 never tracked + // contributions), so a single in-flight splice we contributed to stays loadable there. + let negotiation_contribution = funding_negotiation + .is_some() + .then(|| self.pending_funding.negotiation_contribution.as_ref()) + .flatten(); + let candidates = &self.pending_funding.negotiated_candidates; + debug_assert!( + candidates + .iter() + .skip_while(|candidate| candidate.contribution.is_none()) + .all(|candidate| candidate.contribution.is_some()), + "contributions must form a suffix of the negotiated candidates", + ); + // TLV 3 exposes only the first candidate's funding: the single-splice view LDK 0.2 + // understands. The authoritative candidate list — each funding bundled with its + // contribution — goes in the odd TLV 11, which current reads and 0.2 skips. A single + // non-contributory splice is fully captured by TLV 3 alone, so the bundle is then omitted. + // When a single splice does carry a contribution, 0.2 skips it (and operates the splice + // without it), so it need not block 0.2 from loading. + // + // The even TLV 14 is the only thing that makes 0.2 refuse, and it's written exactly when + // there is more than one negotiation round (RBF) — the one thing 0.2 cannot operate. The + // odd contribution fields are safe despite being load-bearing for RBF: this gate makes 0.2 + // refuse the whole channel in that case, so no reader ever skips them when they matter. + let first_funding = Iterable(candidates.iter().take(1).map(|candidate| &candidate.funding)); + let any_contribution = candidates.iter().any(|candidate| candidate.contribution.is_some()); + let negotiated_candidates = + (candidates.len() > 1 || any_contribution).then(|| Iterable(candidates.iter())); + let is_rbf = candidates.len() + usize::from(funding_negotiation.is_some()) > 1; + let rbf_gate = is_rbf.then_some(()); write_tlv_fields!(writer, { (1, funding_negotiation, upgradable_option), - (3, self.pending_funding.negotiated_candidates, required_vec), + (3, first_funding, required), (5, self.pending_funding.sent_funding_txid, option), (7, self.pending_funding.received_funding_txid, option), - (8, self.pending_funding.last_funding_feerate_sat_per_1000_weight, option), - (10, self.pending_funding.contributions[..contributions_len], optional_vec), + (9, self.pending_funding.last_funding_feerate_sat_per_1000_weight, option), + (11, negotiated_candidates, option), + (13, negotiation_contribution, option), + (14, rbf_gate, option), }); Ok(()) } @@ -3071,14 +3115,57 @@ impl Writeable for PendingFundingWriteable<'_> { impl Readable for PendingFunding { fn read(reader: &mut R) -> Result { - Ok(_decode_and_build!(reader, Self, { + let mut funding_negotiation = None; + let mut legacy_negotiated_candidates: Option> = None; + let mut sent_funding_txid = None; + let mut received_funding_txid = None; + let mut last_funding_feerate_sat_per_1000_weight = None; + let mut negotiated_candidates: Option> = None; + let mut negotiation_contribution: Option = None; + let mut rbf_gate: Option<()> = None; + + read_tlv_fields!(reader, { (1, funding_negotiation, upgradable_option), - (3, negotiated_candidates, required_vec), + (3, legacy_negotiated_candidates, optional_vec), (5, sent_funding_txid, option), (7, received_funding_txid, option), - (8, last_funding_feerate_sat_per_1000_weight, option), - (10, contributions, optional_vec), - })) + (9, last_funding_feerate_sat_per_1000_weight, option), + (11, negotiated_candidates, optional_vec), + (13, negotiation_contribution, option), + (14, rbf_gate, option), + }); + + // TLV 11 (the candidate list, each funding bundled with its contribution) is authoritative + // when present. It is omitted for a single non-contributory splice (TLV 3 holds its + // funding) and for data written by LDK 0.2 (which only ever wrote TLV 3 and tracked no + // contributions); in both cases the candidates carry no contribution. + let negotiated_candidates = negotiated_candidates.unwrap_or_else(|| { + legacy_negotiated_candidates + .unwrap_or_default() + .into_iter() + .map(|funding| NegotiatedCandidate { funding, contribution: None }) + .collect() + }); + // An in-flight contribution is only meaningful while its negotiation round is alive; + // rounds not surviving serialization round trips have their events handled separately. + if funding_negotiation.is_none() { + negotiation_contribution = None; + } + // TLV 14 only exists to make pre-RBF readers (LDK 0.2) refuse a channel they couldn't + // operate; current reconstructs RBF state from the candidate list, so it's informational. + debug_assert_eq!( + rbf_gate.is_some(), + negotiated_candidates.len() + usize::from(funding_negotiation.is_some()) > 1, + ); + + Ok(PendingFunding { + funding_negotiation, + negotiation_contribution, + negotiated_candidates, + sent_funding_txid, + received_funding_txid, + last_funding_feerate_sat_per_1000_weight, + }) } } @@ -3228,22 +3315,109 @@ impl PendingFunding { feerate_sat_per_kw >= min_feerate } + /// All stored contributions: those of the negotiated candidates followed by the in-flight + /// negotiation round's, if any. + fn contributions(&self) -> impl Iterator + '_ { + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .chain(self.negotiation_contribution.as_ref()) + } + fn contributed_inputs(&self) -> impl Iterator + '_ { - self.contributions.iter().flat_map(|c| c.contributed_inputs()) + self.contributions().flat_map(|c| c.contributed_inputs()) } fn contributed_outputs(&self) -> impl Iterator + '_ { - self.contributions.iter().flat_map(|c| c.contributed_outputs()) + self.contributions().flat_map(|c| c.contributed_outputs()) } fn prior_contributed_inputs(&self) -> impl Iterator + '_ { - let len = self.contributions.len(); - self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_inputs()) + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|c| c.contributed_inputs()) } fn prior_contributed_outputs(&self) -> impl Iterator + '_ { - let len = self.contributions.len(); - self.contributions[..len.saturating_sub(1)].iter().flat_map(|c| c.contributed_outputs()) + self.negotiated_candidates + .iter() + .filter_map(|candidate| candidate.contribution.as_ref()) + .flat_map(|c| c.contributed_outputs()) + } + + /// Our most recent contribution across rounds, including any round still under negotiation. + fn latest_contribution(&self) -> Option<&FundingContribution> { + self.negotiation_contribution.as_ref().or_else(|| { + self.negotiated_candidates.last().and_then(|candidate| candidate.contribution.as_ref()) + }) + } + + fn to_details( + &self, context: &ChannelContext, best_block_height: u32, + queued_contribution: Option<&FundingContribution>, + ) -> SpliceDetails { + let negotiation = self.funding_negotiation.as_ref().map(|negotiation| { + let status = match negotiation { + FundingNegotiation::AwaitingAck { .. } => SpliceNegotiationStatus::AwaitingAck, + FundingNegotiation::ConstructingTransaction { .. } => { + SpliceNegotiationStatus::ConstructingTransaction + }, + FundingNegotiation::AwaitingSignatures { .. } => { + SpliceNegotiationStatus::AwaitingSignatures + }, + }; + SpliceNegotiationDetails { + status, + is_initiator: negotiation.is_initiator(), + funding_feerate_sat_per_1000_weight: negotiation + .funding_feerate_sat_per_1000_weight(), + new_channel_value_satoshis: negotiation + .as_funding() + .map(|funding| funding.get_value_satoshis()), + txid: negotiation.as_funding().and_then(|funding| funding.get_funding_txid()), + contribution: self.negotiation_contribution.clone(), + } + }); + let candidates = self + .negotiated_candidates + .iter() + .map(|candidate| SpliceCandidateDetails { + txid: candidate + .funding + .get_funding_txid() + .expect("negotiated candidates should have a funding txid"), + new_channel_value_satoshis: candidate.funding.get_value_satoshis(), + contribution: candidate.contribution.clone(), + }) + .collect(); + // At most one candidate can confirm, as they all double-spend the same input. + let confirmed_candidate = self.negotiated_candidates.iter().find_map(|candidate| { + let confirmations = candidate.funding.get_funding_tx_confirmations(best_block_height); + (confirmations > 0).then(|| { + let txid = candidate + .funding + .get_funding_txid() + .expect("negotiated candidates should have a funding txid"); + ConfirmedSpliceCandidate { + txid, + confirmations, + confirmations_required: context + .minimum_depth(&candidate.funding) + .expect("set for a ready channel"), + // The `splice_locked` we sent always refers to the confirmed candidate, as it + // is cleared if that candidate is ever unconfirmed by a reorg. + splice_locked_sent: self.sent_funding_txid == Some(txid), + } + }) + }); + SpliceDetails { + negotiation, + queued_contribution: queued_contribution.cloned(), + candidates, + confirmed_candidate, + received_splice_locked_txid: self.received_funding_txid, + } } fn check_get_splice_locked( @@ -3251,7 +3425,7 @@ impl PendingFunding { ) -> Option { debug_assert!(confirmed_funding_index < self.negotiated_candidates.len()); - let funding = &self.negotiated_candidates[confirmed_funding_index]; + let funding = &self.negotiated_candidates[confirmed_funding_index].funding; if !context.check_funding_meets_minimum_depth(funding, height) { return None; } @@ -7185,6 +7359,9 @@ pub struct SpliceFundingNegotiated { /// The outpoint of the channel's splice funding transaction. pub funding_txo: bitcoin::OutPoint, + /// Whether the holder contributed local inputs or outputs to the negotiated splice. + pub has_local_contribution: bool, + /// The features that this channel will operate with. pub channel_type: ChannelTypeFeatures, @@ -7224,8 +7401,7 @@ impl SpliceFundingFailed { } macro_rules! splice_funding_failed_for { - ($self: expr, $is_initiator: expr, $contribution: expr, - $contributed_inputs: ident, $contributed_outputs: ident) => {{ + ($self: expr, $contribution: expr, $contributed_inputs: ident, $contributed_outputs: ident) => {{ let contribution = $contribution; let existing_inputs = $self.pending_splice.as_ref().into_iter().flat_map(|ps| ps.$contributed_inputs()); @@ -7234,17 +7410,16 @@ macro_rules! splice_funding_failed_for { let filtered = contribution.clone().into_unique_contributions(existing_inputs, existing_outputs); match filtered { - None if !$is_initiator => None, - None => Some(SpliceFundingFailed { + None => SpliceFundingFailed { contributed_inputs: vec![], contributed_outputs: vec![], contribution: Some(contribution), - }), - Some((contributed_inputs, contributed_outputs)) => Some(SpliceFundingFailed { + }, + Some((contributed_inputs, contributed_outputs)) => SpliceFundingFailed { contributed_inputs, contributed_outputs, contribution: Some(contribution), - }), + }, } }}; } @@ -7275,16 +7450,10 @@ where /// Builds a [`SpliceFundingFailed`] from a contribution, filtering out inputs/outputs /// that are still committed to a prior splice round. fn splice_funding_failed_for(&self, contribution: FundingContribution) -> SpliceFundingFailed { - // The contribution was never pushed to `contributions`, so `contributed_inputs()` and - // `contributed_outputs()` return only prior rounds' entries for filtering. - splice_funding_failed_for!( - self, - true, - contribution, - contributed_inputs, - contributed_outputs - ) - .expect("is_initiator is true so this always returns Some") + // The contribution was never stored in the pending splice state, so + // `contributed_inputs()` and `contributed_outputs()` return only prior rounds' entries + // for filtering. + splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs) } fn abandon_quiescent_action(&mut self) -> Option { @@ -7326,12 +7495,15 @@ where }) } - fn pending_funding(&self) -> &[FundingScope] { - if let Some(pending_splice) = &self.pending_splice { - pending_splice.negotiated_candidates.as_slice() - } else { - &[] - } + fn negotiated_candidates(&self) -> &[NegotiatedCandidate] { + self.pending_splice + .as_ref() + .map(|pending_splice| pending_splice.negotiated_candidates.as_slice()) + .unwrap_or(&[]) + } + + fn pending_funding(&self) -> impl ExactSizeIterator + '_ { + self.negotiated_candidates().iter().map(|candidate| &candidate.funding) } fn funding_and_pending_funding_iter_mut(&mut self) -> impl Iterator { @@ -7340,10 +7512,40 @@ where .as_mut() .map(|pending_splice| pending_splice.negotiated_candidates.as_mut_slice()) .unwrap_or(&mut []) - .iter_mut(), + .iter_mut() + .map(|candidate| &mut candidate.funding), ) } + /// Returns details about any pending splice attempts for inclusion in + /// [`crate::ln::channel_state::ChannelDetails`]. + pub fn pending_splice_details(&self, best_block_height: u32) -> Option { + // A contribution committed via `funding_contributed` sits in `quiescent_action` until + // quiescence is reached and it becomes a negotiation; surface it as the queued contribution. + let queued_contribution = match &self.quiescent_action { + Some(QuiescentAction::Splice { contribution, .. }) => Some(contribution), + #[cfg(any(test, fuzzing, feature = "_test_utils"))] + Some(QuiescentAction::DoNothing) => None, + None => None, + }; + self.pending_splice + .as_ref() + .map(|pending_splice| { + pending_splice.to_details(&self.context, best_block_height, queued_contribution) + }) + // No `PendingFunding` yet (e.g. a first splice still awaiting quiescence), but a queued + // contribution is still worth surfacing on its own. + .or_else(|| { + queued_contribution.map(|contribution| SpliceDetails { + negotiation: None, + queued_contribution: Some(contribution.clone()), + candidates: Vec::new(), + confirmed_candidate: None, + received_splice_locked_txid: None, + }) + }) + } + fn has_pending_splice_awaiting_signatures(&self) -> bool { self.pending_splice .as_ref() @@ -7426,12 +7628,8 @@ where pending_splice.funding_negotiation.is_some(), "reset_pending_splice_state requires an active funding negotiation" ); - let is_initiator = pending_splice - .funding_negotiation - .take() - .map(|negotiation| negotiation.is_initiator()) - .unwrap_or(false); - let contribution = pending_splice.contributions.pop(); + pending_splice.funding_negotiation.take(); + let contribution = pending_splice.negotiation_contribution.take(); if let Some(ref contribution) = contribution { debug_assert!( pending_splice @@ -7442,19 +7640,13 @@ where ); } - // After pop, `contributed_inputs()` / `contributed_outputs()` return only prior - // rounds for filtering. - let splice_funding_failed = contribution.and_then(|contribution| { - splice_funding_failed_for!( - self, - is_initiator, - contribution, - contributed_inputs, - contributed_outputs - ) + // With the in-flight contribution taken, `contributed_inputs()` / + // `contributed_outputs()` return only prior rounds' entries for filtering. + let splice_funding_failed = contribution.map(|contribution| { + splice_funding_failed_for!(self, contribution, contributed_inputs, contributed_outputs) }); - if self.pending_funding().is_empty() { + if self.negotiated_candidates().is_empty() { self.pending_splice.take(); } @@ -7476,19 +7668,13 @@ where pending_splice.funding_negotiation.is_some(), "maybe_splice_funding_failed requires an active funding negotiation" ); - let is_initiator = pending_splice - .funding_negotiation - .as_ref() - .map(|negotiation| negotiation.is_initiator()) - .unwrap_or(false); - let contribution = pending_splice.contributions.last().cloned()?; - splice_funding_failed_for!( + let contribution = pending_splice.negotiation_contribution.clone()?; + Some(splice_funding_failed_for!( self, - is_initiator, contribution, prior_contributed_inputs, prior_contributed_outputs - ) + )) } #[rustfmt::skip] @@ -8114,7 +8300,7 @@ where } core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| self.context.validate_update_add_htlc(funding, msg, fee_estimator))?; // Now update local state: @@ -8546,7 +8732,7 @@ where let funding_contribution = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) + .and_then(|pending_splice| pending_splice.negotiation_contribution.as_ref()) .cloned(); log_info!( @@ -8609,7 +8795,7 @@ where ) -> Result, ChannelError> { self.commitment_signed_check_state()?; - if !self.pending_funding().is_empty() { + if !self.negotiated_candidates().is_empty() { return Err(ChannelError::close( "Got a single commitment_signed message when expecting a batch".to_owned(), )); @@ -8677,7 +8863,7 @@ where // pending splice transaction has confirmed since receiving the batch. let mut commitment_txs = Vec::with_capacity(self.pending_funding().len() + 1); let mut htlc_data = None; - for funding in core::iter::once(&self.funding).chain(self.pending_funding().iter()) { + for funding in core::iter::once(&self.funding).chain(self.pending_funding()) { let funding_txid = funding.get_funding_txid().expect("Funding txid must be known for pending scope"); let msg = messages.get(&funding_txid).ok_or_else(|| { @@ -9548,11 +9734,29 @@ where funding.get_funding_txo().expect("funding outpoint should be set"); let channel_type = funding.get_channel_type().clone(); let funding_redeem_script = funding.get_funding_redeemscript(); + let has_local_contribution = self + .context + .interactive_tx_signing_session + .as_ref() + .map(|signing_session| signing_session.has_local_contribution()) + .unwrap_or(false); - pending_splice.negotiated_candidates.push(funding); + let contribution = pending_splice.negotiation_contribution.take(); + debug_assert!( + contribution.is_some() + || pending_splice + .negotiated_candidates + .iter() + .all(|candidate| candidate.contribution.is_none()), + "a round following one we contributed to must carry our contribution", + ); + pending_splice + .negotiated_candidates + .push(NegotiatedCandidate { funding, contribution }); let splice_negotiated = SpliceFundingNegotiated { funding_txo: funding_txo.into_bitcoin_outpoint(), + has_local_contribution, channel_type, funding_redeem_script, }; @@ -9572,29 +9776,21 @@ where ); } - let contrib_offset = pending_splice - .negotiated_candidates - .len() - .saturating_sub(pending_splice.contributions.len()); let candidates = pending_splice .negotiated_candidates .iter() - .enumerate() - .map(|(i, funding)| { - let txid = funding + .map(|candidate| { + let txid = candidate + .funding .get_funding_txid() .expect("negotiated candidates should have a funding txid"); - let contribution = i - .checked_sub(contrib_offset) - .and_then(|j| pending_splice.contributions.get(j)) - .cloned(); FundingCandidate { txid, channels: vec![ChannelFunding { counterparty_node_id: self.context.counterparty_node_id, channel_id: self.context.channel_id, purpose: FundingPurpose::Splice, - contribution, + contribution: candidate.contribution.clone(), }], } }) @@ -9755,7 +9951,7 @@ where debug_assert!(!self.funding.get_channel_type().supports_anchor_zero_fee_commitments()); let can_send_update_fee = core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .all(|funding| self.context.can_send_update_fee(funding, feerate_per_kw, fee_estimator, logger)); if !can_send_update_fee { return None; @@ -10106,7 +10302,7 @@ where } core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| FundedChannel::::check_remote_fee(funding.get_channel_type(), fee_estimator, msg.feerate_per_kw, Some(self.context.feerate_per_kw), logger))?; self.context.pending_update_fee = Some((msg.feerate_per_kw, FeeUpdateState::RemoteAnnounced)); @@ -10818,7 +11014,6 @@ where // for this `txid`. let inferred_splice_locked = msg.my_current_funding_locked.as_ref().and_then(|funding_locked| { self.pending_funding() - .iter() .find(|funding| funding.get_funding_txid() == Some(funding_locked.txid)) .and_then(|_| { self.pending_splice.as_ref().and_then(|pending_splice| { @@ -11615,7 +11810,7 @@ where ); core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .try_for_each(|funding| self.context.can_accept_incoming_htlc(funding, dust_exposure_limiting_feerate, &logger)) } @@ -11933,6 +12128,7 @@ where let funding = pending_splice .negotiated_candidates .iter_mut() + .map(|candidate| &mut candidate.funding) .find(|funding| funding.get_funding_txid() == Some(splice_txid)) .unwrap(); @@ -11947,9 +12143,12 @@ where .funding_transaction .as_ref() .expect("Promoted splice funding should have a funding transaction"); - let contributions = core::mem::take(&mut pending_splice.contributions); - contributions + let candidates = core::mem::take(&mut pending_splice.negotiated_candidates); + let negotiation_contribution = pending_splice.negotiation_contribution.take(); + candidates .into_iter() + .filter_map(|candidate| candidate.contribution) + .chain(negotiation_contribution) .filter_map(|contribution| { contribution.into_unique_contributions( promoted_tx.input.iter().map(|i| i.previous_output), @@ -12038,7 +12237,9 @@ where let mut confirmed_funding_index = None; let mut funding_already_confirmed = false; - for (index, funding) in pending_splice.negotiated_candidates.iter_mut().enumerate() { + let candidates = + pending_splice.negotiated_candidates.iter_mut().map(|candidate| &mut candidate.funding); + for (index, funding) in candidates.enumerate() { if self.context.check_for_funding_tx_confirmed( funding, block_hash, height, index_in_block, &mut confirmed_tx, logger, )? { @@ -12198,7 +12399,8 @@ where if let Some(pending_splice) = &mut self.pending_splice { let mut confirmed_funding_index = None; - for (index, funding) in pending_splice.negotiated_candidates.iter().enumerate() { + let candidates = pending_splice.negotiated_candidates.iter().map(|candidate| &candidate.funding); + for (index, funding) in candidates.enumerate() { if funding.funding_tx_confirmation_height != 0 { if confirmed_funding_index.is_some() { let err_reason = "splice tx of another pending funding already confirmed"; @@ -12210,7 +12412,8 @@ where } if let Some(confirmed_funding_index) = confirmed_funding_index { - let funding = &mut pending_splice.negotiated_candidates[confirmed_funding_index]; + let funding = + &mut pending_splice.negotiated_candidates[confirmed_funding_index].funding; // Check if the splice funding transaction was unconfirmed if funding.get_funding_tx_confirmations(height) == 0 { @@ -12266,7 +12469,7 @@ where pub fn get_relevant_txids(&self) -> impl Iterator)> + '_ { core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| { ( funding.get_funding_txid(), @@ -12710,21 +12913,23 @@ where .as_ref() .map(|n| n.funding_feerate_sat_per_1000_weight()) }); - debug_assert!( - prev_feerate.is_some(), - "pending_splice should have last_funding_feerate or funding_negotiation", - ); - let min_rbf_feerate = prev_feerate.map(min_rbf_feerate); + let prev_feerate = match prev_feerate { + Some(prev_feerate) => prev_feerate, + None => { + // The feerate and our contribution are only persisted by LDK 0.3+, so their + // absence means this splice was last written by an older version (negotiated + // there, or round-tripped 0.3 -> 0.2 -> 0.3) and cannot be RBF'd. + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} has a pending splice from a prior LDK version and cannot be spliced again", + self.context.channel_id(), + ), + }); + }, + }; + let min_rbf_feerate = Some(min_rbf_feerate(prev_feerate)); let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() { - if let Some(prior) = self - .pending_splice - .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) - { - Some(prior.clone()) - } else { - None - } + pending_splice.latest_contribution().cloned() } else { None }; @@ -12968,6 +13173,18 @@ where contribution }; + // A queued splice never coexists with a negotiation we initiated: we return early above if + // one is already in flight, and a queued action is cleared the moment it becomes our + // negotiation at quiescence. It may coexist with a counterparty-initiated negotiation (e.g. + // queuing our own contribution while accepting their splice), so we only rule out our own. + debug_assert!( + self.pending_splice + .as_ref() + .and_then(|pending_splice| pending_splice.funding_negotiation.as_ref()) + .map_or(true, |funding_negotiation| !funding_negotiation.is_initiator()), + "A queued splice must not coexist with a funding negotiation we initiated", + ); + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } @@ -13014,11 +13231,11 @@ where FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), + negotiation_contribution: None, negotiated_candidates: vec![], sent_funding_txid: None, received_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, - contributions: vec![], }); msgs::SpliceInit { @@ -13040,6 +13257,7 @@ where .negotiated_candidates .first() .unwrap() + .funding .get_holder_pubkeys() .funding_pubkey; @@ -13151,11 +13369,13 @@ where /// Checks during handling splice_init pub fn validate_splice_init(&self, msg: &msgs::SpliceInit) -> Result<(), ChannelError> { - if self.holder_commitment_point.current_point().is_none() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} commitment point needs to be advanced once before spliced", - self.context.channel_id(), - ))); + // - If it has received shutdown: + // MUST send a warning and close the connection or send an error + // and fail the channel. + if !self.context.is_live() { + return Err(ChannelError::WarnAndDisconnect( + "Splicing requested on a channel that is not live".to_owned(), + )); } if !self.context.channel_state.is_quiescent() { @@ -13170,15 +13390,6 @@ where ))); } - // - If it has received shutdown: - // MUST send a warning and close the connection or send an error - // and fail the channel. - if !self.context.is_live() { - return Err(ChannelError::WarnAndDisconnect( - "Splicing requested on a channel that is not live".to_owned(), - )); - } - let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); if their_funding_contribution == SignedAmount::ZERO { return Err(ChannelError::WarnAndDisconnect(format!( @@ -13187,6 +13398,12 @@ where ))); } + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::Abort(AbortReason::InternalError( + "Commitment point needs to be advanced once before spliced".into(), + ))); + } + Ok(()) } @@ -13203,13 +13420,10 @@ where counterparty_funding_pubkey, our_new_holder_keys, min_funding_satoshis, - ) - .map_err(|e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e))?; + )?; let (post_splice_holder_balance, post_splice_counterparty_balance) = - self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope).map_err( - |e| format!("Channel {} cannot be spliced; {}", self.context.channel_id(), e), - )?; + self.get_holder_counterparty_balances_floor_incl_fee(&candidate_scope)?; let holder_selected_channel_reserve = Amount::from_sat(candidate_scope.holder_selected_channel_reserve_satoshis); @@ -13219,25 +13433,23 @@ where // We allow parties to draw from their previous reserve, as long as they satisfy their v2 reserve if our_funding_contribution != SignedAmount::ZERO { - post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve) - .ok_or(format!( - "Channel {} cannot be {}; our post-splice channel balance {} is smaller than their selected v2 reserve {}", - self.context.channel_id(), - if our_funding_contribution.is_positive() { "spliced in" } else { "spliced out" }, - post_splice_holder_balance, - counterparty_selected_channel_reserve, - ))?; + post_splice_holder_balance.checked_sub(counterparty_selected_channel_reserve).ok_or( + format!( + "Our post-splice channel balance {} is smaller than their selected v2 reserve {}", + post_splice_holder_balance, + counterparty_selected_channel_reserve, + ), + )?; } if their_funding_contribution != SignedAmount::ZERO { - post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve) - .ok_or(format!( - "Channel {} cannot be {}; their post-splice channel balance {} is smaller than our selected v2 reserve {}", - self.context.channel_id(), - if their_funding_contribution.is_positive() { "spliced in" } else { "spliced out" }, - post_splice_counterparty_balance, - holder_selected_channel_reserve, - ))?; + post_splice_counterparty_balance.checked_sub(holder_selected_channel_reserve).ok_or( + format!( + "Their post-splice channel balance {} is smaller than our selected v2 reserve {}", + post_splice_counterparty_balance, + holder_selected_channel_reserve, + ), + )?; } #[cfg(debug_assertions)] @@ -13348,7 +13560,11 @@ where holder_pubkeys, min_funding_satoshis, ) - .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; + .map_err(|e| { + self.quiescent_negotiation_err(ChannelError::Abort( + AbortReason::InvalidContribution(e), + )) + })?; // Adjust for the feerate and clone so we can store it for future RBF re-use. let (adjusted_contribution, our_funding_inputs, our_funding_outputs) = @@ -13388,11 +13604,11 @@ where ); self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), + negotiation_contribution: adjusted_contribution, negotiated_candidates: Vec::new(), received_funding_txid: None, sent_funding_txid: None, last_funding_feerate_sat_per_1000_weight: None, - contributions: adjusted_contribution.into_iter().collect(), }); Ok(msgs::SpliceAck { @@ -13407,57 +13623,56 @@ where fn validate_tx_init_rbf( &self, msg: &msgs::TxInitRbf, fee_estimator: &LowerBoundedFeeEstimator, ) -> Result<(ChannelPublicKeys, PublicKey), ChannelError> { - if self.holder_commitment_point.current_point().is_none() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} commitment point needs to be advanced once before RBF", - self.context.channel_id(), - ))); + if !self.context.is_live() { + return Err(ChannelError::WarnAndDisconnect( + "RBF requested on a channel that is not live".to_owned(), + )); } - if !self.context.channel_state.is_quiescent() { return Err(ChannelError::WarnAndDisconnect("Quiescence needed for RBF".to_owned())); } - self.is_rbf_compatible().map_err(|msg| ChannelError::WarnAndDisconnect(msg))?; + if self.holder_commitment_point.current_point().is_none() { + return Err(ChannelError::Abort(AbortReason::InternalError( + "Commitment point needs to be advanced once before RBF".into(), + ))); + } - let pending_splice = match &self.pending_splice { - Some(pending_splice) => pending_splice, - None => { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} has no pending splice to RBF", - self.context.channel_id(), - ))); - }, - }; + self.is_rbf_compatible() + .map_err(|msg| ChannelError::Abort(AbortReason::RbfUnavailable(msg)))?; + + let (pending_splice, last_candidate) = self + .pending_splice + .as_ref() + .filter(|pending_splice| !pending_splice.negotiated_candidates.is_empty()) + .map(|pending_splice| { + ( + pending_splice, + pending_splice.negotiated_candidates.last().expect("checked above"), + ) + }) + .ok_or_else(|| { + ChannelError::Abort(AbortReason::RbfUnavailable( + "No pending splice available to RBF".into(), + )) + })?; if pending_splice.funding_negotiation.is_some() { return Err(ChannelError::Abort(AbortReason::NegotiationInProgress)); } if pending_splice.received_funding_txid.is_some() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} counterparty already sent splice_locked, cannot RBF", - self.context.channel_id(), + return Err(ChannelError::Abort(AbortReason::RbfUnavailable( + "Already received splice_locked".into(), ))); } if pending_splice.sent_funding_txid.is_some() { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} already sent splice_locked, cannot RBF", - self.context.channel_id(), + return Err(ChannelError::Abort(AbortReason::RbfUnavailable( + "Already sent splice_locked".into(), ))); } - let last_candidate = match pending_splice.negotiated_candidates.last() { - Some(candidate) => candidate, - None => { - return Err(ChannelError::WarnAndDisconnect(format!( - "Channel {} has no negotiated splice candidates to RBF", - self.context.channel_id(), - ))); - }, - }; - let prev_feerate = pending_splice.last_funding_feerate_sat_per_1000_weight.unwrap_or_else(|| { fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::UrgentOnChainSweep) @@ -13475,8 +13690,8 @@ where // Reuse funding pubkeys from the last negotiated candidate since all RBF candidates // for the same splice share the same funding output script. Ok(( - last_candidate.get_holder_pubkeys().clone(), - *last_candidate.counterparty_funding_pubkey(), + last_candidate.funding.get_holder_pubkeys().clone(), + *last_candidate.funding.counterparty_funding_pubkey(), )) } @@ -13500,7 +13715,7 @@ where } else if let Some(prior) = self .pending_splice .as_ref() - .and_then(|pending_splice| pending_splice.contributions.last()) + .and_then(|pending_splice| pending_splice.latest_contribution()) { let net_value = holder_balance .ok_or_else(|| ChannelError::Abort(AbortReason::InsufficientRbfFeerate)) @@ -13531,7 +13746,11 @@ where holder_pubkeys, min_funding_satoshis, ) - .map_err(|e| self.quiescent_negotiation_err(ChannelError::WarnAndDisconnect(e)))?; + .map_err(|e| { + self.quiescent_negotiation_err(ChannelError::Abort( + AbortReason::InvalidContribution(e), + )) + })?; // Consume the appropriate contribution source. let (our_funding_inputs, our_funding_outputs) = if queued_net_value.is_some() { @@ -13543,16 +13762,14 @@ where self.pending_splice .as_mut() .expect("pending_splice is Some") - .contributions - .push(adjusted_contribution.clone()); + .negotiation_contribution = Some(adjusted_contribution.clone()); adjusted_contribution.into_tx_parts() } else if prior_net_value.is_some() { let prior_contribution = self .pending_splice .as_ref() .expect("pending_splice is Some") - .contributions - .last() + .latest_contribution() .expect("prior_net_value was Some") .clone(); let adjusted_contribution = prior_contribution @@ -13561,8 +13778,7 @@ where self.pending_splice .as_mut() .expect("pending_splice is Some") - .contributions - .push(adjusted_contribution.clone()); + .negotiation_contribution = Some(adjusted_contribution.clone()); adjusted_contribution.into_tx_parts() } else { Default::default() @@ -13618,10 +13834,12 @@ where }; let last_candidate = pending_splice.negotiated_candidates.last().ok_or_else(|| { - ChannelError::WarnAndDisconnect("No negotiated splice candidates for RBF".to_owned()) + ChannelError::Abort(AbortReason::RbfUnavailable( + "No pending splice available to RBF".into(), + )) })?; - let holder_pubkeys = last_candidate.get_holder_pubkeys().clone(); - let counterparty_funding_pubkey = *last_candidate.counterparty_funding_pubkey(); + let holder_pubkeys = last_candidate.funding.get_holder_pubkeys().clone(); + let counterparty_funding_pubkey = *last_candidate.funding.counterparty_funding_pubkey(); let new_funding = self .validate_splice_contributions( @@ -13631,7 +13849,7 @@ where holder_pubkeys, min_funding_satoshis, ) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + .map_err(|e| ChannelError::Abort(AbortReason::InvalidContribution(e)))?; Ok(new_funding) } @@ -13708,8 +13926,6 @@ where fn validate_splice_ack( &self, msg: &msgs::SpliceAck, min_funding_satoshis: u64, ) -> Result { - // TODO(splicing): Add check that we are the splice (quiescence) initiator - let pending_splice = self .pending_splice .as_ref() @@ -13732,7 +13948,7 @@ where new_keys, min_funding_satoshis, ) - .map_err(|e| ChannelError::WarnAndDisconnect(e))?; + .map_err(|e| ChannelError::Abort(AbortReason::InvalidContribution(e)))?; Ok(new_funding) } @@ -13886,7 +14102,7 @@ where if !pending_splice .negotiated_candidates .iter() - .any(|funding| funding.get_funding_txid() == Some(msg.splice_txid)) + .any(|candidate| candidate.funding.get_funding_txid() == Some(msg.splice_txid)) { let err = "unknown splice funding txid"; return Err(ChannelError::close(err.to_string())); @@ -14084,7 +14300,7 @@ where &self, fee_estimator: &LowerBoundedFeeEstimator, ) -> Result { let init = self.context.get_available_balances_for_scope(&self.funding, fee_estimator)?; - self.pending_funding().iter().try_fold(init, |acc, funding| { + self.pending_funding().try_fold(init, |acc, funding| { let e = self.context.get_available_balances_for_scope(funding, fee_estimator)?; Ok(AvailableBalances { inbound_capacity_msat: acc.inbound_capacity_msat.min(e.inbound_capacity_msat), @@ -14146,7 +14362,7 @@ where } self.context.resend_order = RAACommitmentOrder::RevokeAndACKFirst; - let update = if self.pending_funding().is_empty() { + let update = if self.negotiated_candidates().is_empty() { let (htlcs_ref, counterparty_commitment_tx) = self.build_commitment_no_state_update(&self.funding, logger); let htlc_outputs = htlcs_ref @@ -14177,7 +14393,7 @@ where } else { let mut htlc_data = None; let commitment_txs = core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| { let (htlcs_ref, counterparty_commitment_tx) = self.build_commitment_no_state_update(funding, logger); @@ -14239,7 +14455,7 @@ where &self, logger: &L, ) -> Result, ChannelError> { core::iter::once(&self.funding) - .chain(self.pending_funding().iter()) + .chain(self.pending_funding()) .map(|funding| self.send_commitment_no_state_update_for_funding(funding, logger)) .collect::, ChannelError>>() } @@ -14710,14 +14926,14 @@ where } let tx_init_rbf = self.send_tx_init_rbf(context); self.pending_splice.as_mut().unwrap() - .contributions.push(prior_contribution); + .negotiation_contribution = Some(prior_contribution); return Ok(Some(StfuResponse::TxInitRbf(tx_init_rbf))); } let splice_init = self.send_splice_init(context); debug_assert!(self.pending_splice.is_some()); self.pending_splice.as_mut().unwrap() - .contributions.push(prior_contribution); + .negotiation_contribution = Some(prior_contribution); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing, feature = "_test_utils"))] @@ -16380,7 +16596,7 @@ impl Writeable for FundedChannel { // resumed on reestablishment, but keep any already-negotiated candidates. let reset_funding_negotiation = self.should_reset_pending_splice_state(true); let should_persist_pending_splice = - !reset_funding_negotiation || !self.pending_funding().is_empty(); + !reset_funding_negotiation || !self.negotiated_candidates().is_empty(); let pending_splice = should_persist_pending_splice .then(|| ()) .and_then(|_| self.pending_splice.as_ref()) diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 6e5d633e920..d69d0ce901a 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -12,10 +12,12 @@ use alloc::vec::Vec; use bitcoin::secp256k1::PublicKey; +use bitcoin::Txid; use crate::chain::chaininterface::{FeeEstimator, LowerBoundedFeeEstimator}; use crate::chain::transaction::OutPoint; use crate::ln::channel::Channel; +use crate::ln::funding::FundingContribution; use crate::ln::types::ChannelId; use crate::sign::SignerProvider; use crate::types::features::{ChannelTypeFeatures, InitFeatures}; @@ -494,6 +496,14 @@ pub struct ChannelDetails { /// /// [`ChannelConfig::max_dust_htlc_exposure`]: crate::util::config::ChannelConfig::max_dust_htlc_exposure pub current_dust_exposure_msat: Option, + /// Details of any pending splice attempts on this channel. + /// + /// This includes a contribution we have committed but not yet begun negotiating, any splice + /// currently under negotiation with our counterparty, and any negotiated splice or RBF attempts + /// waiting on sufficient on-chain confirmations. This will be `None` if no splice is pending. + /// + /// This field will be `None` for objects serialized with LDK versions prior to 0.3. + pub splice_details: Option, } impl ChannelDetails { @@ -619,6 +629,9 @@ impl ChannelDetails { pending_inbound_htlcs: context.get_pending_inbound_htlc_details(funding), pending_outbound_htlcs: context.get_pending_outbound_htlc_details(funding), current_dust_exposure_msat: Some(balance.dust_exposure_msat), + splice_details: channel + .as_funded() + .and_then(|chan| chan.pending_splice_details(best_block_height)), } } } @@ -661,11 +674,180 @@ impl_ser_tlv_based!(ChannelDetails, { (45, pending_outbound_htlcs, optional_vec), (47, funding_redeem_script, option), (49, current_dust_exposure_msat, option), + (51, splice_details, option), (_unused, user_channel_id, (static_value, _user_channel_id_low.unwrap_or(0) as u128 | ((_user_channel_id_high.unwrap_or(0) as u128) << 64) )), }); +/// Details of pending splice attempts on a channel, as returned in +/// [`ChannelDetails::splice_details`]. +/// +/// This includes a contribution we have committed but not yet begun negotiating, any splice +/// currently under negotiation with the counterparty, and any negotiated splice or RBF attempts +/// waiting on sufficient on-chain confirmations. +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceDetails { + /// A splice or RBF attempt currently being negotiated with the counterparty, if any. + /// + /// Note that a negotiation which has not yet reached + /// [`SpliceNegotiationStatus::AwaitingSignatures`] does not survive a restart, so this only + /// reflects in-memory negotiation state. + pub negotiation: Option, + /// Our contribution to a splice we have committed to via + /// [`ChannelManager::funding_contributed`] but which has not yet begun negotiating, as it is + /// awaiting quiescence with the counterparty, if any. + /// + /// Whether we end up the initiator of the resulting negotiation is not settled until quiescence + /// is reached (the counterparty may have its own splice in flight, as in + /// [`negotiation`]), so this only reflects that a contribution is queued. Like the in-flight + /// negotiation states, it does not survive a restart. + /// + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + /// [`negotiation`]: Self::negotiation + pub queued_contribution: Option, + /// Negotiated splice transactions that have not yet reached sufficient confirmations by both + /// counterparties to have exchanged `splice_locked`, in order: the original negotiation + /// followed by any RBF replacements. + /// + /// More than one entry indicates the use of RBF; at most one of these candidates will + /// ultimately confirm. + pub candidates: Vec, + /// The negotiated candidate that has confirmed on-chain, if any, along with its confirmation + /// progress. + /// + /// At most one candidate can confirm, as the candidates all double-spend the same input, so + /// this identifies the single confirming candidate rather than tracking confirmations on each. + pub confirmed_candidate: Option, + /// The txid announced in the `splice_locked` received from the counterparty, i.e., the + /// candidate that they consider to have sufficient confirmations. + /// + /// Unlike the `splice_locked` we sent (see [`ConfirmedSpliceCandidate::splice_locked_sent`]), + /// this need not match [`confirmed_candidate`]: during a reorg, our counterparty may observe a + /// different candidate confirm. + /// + /// [`confirmed_candidate`]: Self::confirmed_candidate + pub received_splice_locked_txid: Option, +} + +impl_ser_tlv_based!(SpliceDetails, { + (1, negotiation, option), + (3, queued_contribution, option), + (5, candidates, required_vec), + (7, confirmed_candidate, option), + (9, received_splice_locked_txid, option), +}); + +/// Details of a splice or RBF attempt currently being negotiated with the counterparty. +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceNegotiationDetails { + /// How far the negotiation has progressed. + pub status: SpliceNegotiationStatus, + /// Whether we initiated the negotiation. + pub is_initiator: bool, + /// The feerate of the splice transaction under negotiation, denominated in satoshi per 1000 + /// weight units. + pub funding_feerate_sat_per_1000_weight: u32, + /// The value, in satoshis, of the channel once the splice transaction under negotiation + /// confirms and is promoted. + /// + /// This will be `None` while [`SpliceNegotiationStatus::AwaitingAck`], as the value is not + /// known until both counterparties' contributions have been exchanged. + pub new_channel_value_satoshis: Option, + /// The txid of the splice transaction under negotiation. + /// + /// This will be `None` until [`SpliceNegotiationStatus::AwaitingSignatures`], as the txid is + /// not known until the transaction has been fully constructed. + pub txid: Option, + /// Our contribution to the splice under negotiation, or `None` if we are not contributing. + /// + /// Note that for a counterparty-initiated RBF attempt, this is the prior round's contribution + /// adjusted to the new feerate. + pub contribution: Option, +} + +impl_ser_tlv_based!(SpliceNegotiationDetails, { + (1, status, required), + (3, is_initiator, required), + (5, funding_feerate_sat_per_1000_weight, required), + (7, new_channel_value_satoshis, option), + (9, txid, option), + (11, contribution, option), +}); + +/// The status of a splice or RBF negotiation in progress with the counterparty. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpliceNegotiationStatus { + /// We sent `splice_init` or `tx_init_rbf` and are awaiting the counterparty's acknowledgement. + AwaitingAck, + /// The splice transaction is being interactively constructed. + ConstructingTransaction, + /// The splice transaction has been negotiated and is awaiting signatures from both + /// counterparties. + AwaitingSignatures, +} + +impl_ser_tlv_based_enum!(SpliceNegotiationStatus, + (0, AwaitingAck) => {}, + (2, ConstructingTransaction) => {}, + (4, AwaitingSignatures) => {}, +); + +/// Details of a negotiated splice transaction that has not yet reached sufficient confirmations +/// by both counterparties to have exchanged `splice_locked`. +#[derive(Clone, Debug, PartialEq)] +pub struct SpliceCandidateDetails { + /// The txid of the splice transaction. + pub txid: Txid, + /// The value, in satoshis, of the channel once this candidate confirms and is promoted. + pub new_channel_value_satoshis: u64, + /// Our contribution to this candidate, or `None` if we did not contribute. + /// + /// Once a candidate includes our contribution, every later candidate does as well: RBF + /// attempts carry the contribution forward (possibly adjusted to a new feerate) rather than + /// dropping it, preserving the splice intention. + /// + /// Note that [`FundingContribution::feerate`] is the feerate used when selecting the + /// contribution's inputs, which is not necessarily the exact feerate of the negotiated + /// transaction. + pub contribution: Option, +} + +impl_ser_tlv_based!(SpliceCandidateDetails, { + (1, txid, required), + (3, new_channel_value_satoshis, required), + (5, contribution, option), +}); + +/// The confirmation progress of the negotiated splice candidate that has confirmed on-chain, as +/// exposed in [`SpliceDetails::confirmed_candidate`]. +/// +/// At most one candidate can confirm, as the candidates all double-spend the same input, so this +/// identifies the single confirming candidate by its txid. +#[derive(Clone, Debug, PartialEq)] +pub struct ConfirmedSpliceCandidate { + /// The txid of the candidate that has confirmed on-chain. This matches the [`txid`] of one of + /// the [`SpliceDetails::candidates`]. + /// + /// [`txid`]: SpliceCandidateDetails::txid + pub txid: Txid, + /// The current number of confirmations of the candidate's transaction. + pub confirmations: u32, + /// The number of confirmations required before `splice_locked` can be sent for the candidate. + pub confirmations_required: u32, + /// Whether we have sent `splice_locked` for this candidate, i.e., we consider it to have + /// sufficient confirmations. The `splice_locked` we sent always refers to this confirmed + /// candidate, so it is tracked here rather than as a separate txid. + pub splice_locked_sent: bool, +} + +impl_ser_tlv_based!(ConfirmedSpliceCandidate, { + (1, txid, required), + (3, confirmations, required), + (5, confirmations_required, required), + (7, splice_locked_sent, required), +}); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] /// Further information on the details of the channel shutdown. /// Upon channels being forced closed (i.e. commitment transaction confirmation detected @@ -718,7 +900,10 @@ mod tests { }, }; - use super::{ChannelCounterparty, ChannelDetails, ChannelShutdownState}; + use super::{ + ChannelCounterparty, ChannelDetails, ChannelShutdownState, ConfirmedSpliceCandidate, + SpliceCandidateDetails, SpliceDetails, SpliceNegotiationDetails, SpliceNegotiationStatus, + }; #[test] fn test_channel_details_serialization() { @@ -783,6 +968,29 @@ mod tests { is_dust: false, }], current_dust_exposure_msat: Some(150_000), + splice_details: Some(SpliceDetails { + negotiation: Some(SpliceNegotiationDetails { + status: SpliceNegotiationStatus::ConstructingTransaction, + is_initiator: true, + funding_feerate_sat_per_1000_weight: 1000, + new_channel_value_satoshis: Some(70_000), + txid: None, + contribution: None, + }), + queued_contribution: None, + candidates: vec![SpliceCandidateDetails { + txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(), + new_channel_value_satoshis: 60_000, + contribution: None, + }], + confirmed_candidate: Some(ConfirmedSpliceCandidate { + txid: bitcoin::Txid::from_slice(&[7; 32]).unwrap(), + confirmations: 3, + confirmations_required: 6, + splice_locked_sent: false, + }), + received_splice_locked_txid: Some(bitcoin::Txid::from_slice(&[7; 32]).unwrap()), + }), }; let mut buffer = Vec::new(); channel_details.write(&mut buffer).unwrap(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 49392264709..0fb13938f99 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -1147,7 +1147,7 @@ impl MsgHandleErrInternal { fn from_chan_no_close(err: ChannelError, channel_id: ChannelId) -> Self { let tx_abort = match &err { - &ChannelError::Abort(reason) => Some(reason.into_tx_abort_msg(channel_id)), + ChannelError::Abort(reason) => Some(reason.clone().into_tx_abort_msg(channel_id)), _ => None, }; let err = match err { @@ -6781,8 +6781,9 @@ impl< /// /// Calling this method will commence the process of creating a new funding transaction for the /// channel. Once the funding transaction has been constructed, an [`Event::SpliceNegotiated`] - /// will be emitted. At this point, any inputs contributed to the splice can only be re-spent - /// if an [`Event::DiscardFunding`] is seen. + /// will be emitted if the negotiated transaction includes local inputs or outputs. At this + /// point, any inputs contributed to the splice can only be re-spent if an + /// [`Event::DiscardFunding`] is seen. /// /// If any failures occur while negotiating the funding transaction, an /// [`Event::SpliceNegotiationFailed`] will be emitted. Any contributed inputs no longer used @@ -7005,18 +7006,20 @@ impl< ); } if let Some(splice_negotiated) = splice_negotiated { - self.pending_events.lock().unwrap().push_back(( - events::Event::SpliceNegotiated { - channel_id: *channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan.context().get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated - .funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id: *channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context().get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } } if chan.context().is_connected() { @@ -11199,17 +11202,19 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .as_mut() .and_then(|v| v.splice_negotiated.take()) { - pending_events.push_back(( - events::Event::SpliceNegotiated { - channel_id: channel.context.channel_id(), - counterparty_node_id, - user_channel_id: channel.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated.funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + pending_events.push_back(( + events::Event::SpliceNegotiated { + channel_id: channel.context.channel_id(), + counterparty_node_id, + user_channel_id: channel.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated.funding_redeem_script, + }, + None, + )); + } } } @@ -12299,18 +12304,20 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ // which also terminates quiescence. let needs_holding_cell_release = splice_negotiated.is_some(); if let Some(splice_negotiated) = splice_negotiated { - self.pending_events.lock().unwrap().push_back(( - events::Event::SpliceNegotiated { - channel_id: msg.channel_id, - counterparty_node_id: *counterparty_node_id, - user_channel_id: chan.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated - .funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id: msg.channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: chan.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } } let holding_cell_res = if needs_holding_cell_release { self.check_free_peer_holding_cells(peer_state) @@ -14161,17 +14168,20 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ .and_then(|funding_tx_signed| funding_tx_signed.splice_negotiated.take()) { *needs_holding_cell_release = true; - self.pending_events.lock().unwrap().push_back(( - events::Event::SpliceNegotiated { - channel_id, - counterparty_node_id: node_id, - user_channel_id: funded_chan.context.get_user_id(), - new_funding_txo: splice_negotiated.funding_txo, - channel_type: splice_negotiated.channel_type, - new_funding_redeem_script: splice_negotiated.funding_redeem_script, - }, - None, - )); + if splice_negotiated.has_local_contribution { + self.pending_events.lock().unwrap().push_back(( + events::Event::SpliceNegotiated { + channel_id, + counterparty_node_id: node_id, + user_channel_id: funded_chan.context.get_user_id(), + new_funding_txo: splice_negotiated.funding_txo, + channel_type: splice_negotiated.channel_type, + new_funding_redeem_script: splice_negotiated + .funding_redeem_script, + }, + None, + )); + } } if let Some(broadcast_tx) = msgs.signed_closing_tx { log_info!(logger, "Broadcasting closing tx {}", log_tx!(broadcast_tx)); diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index dfb702a2657..6769e2de3e5 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -91,7 +91,7 @@ impl SerialIdExt for SerialId { } } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) enum AbortReason { InvalidStateTransition, UnexpectedCounterpartyMessage, @@ -142,6 +142,10 @@ pub(crate) enum AbortReason { /// /// [`ChannelManager::cancel_funding_contributed`]: crate::ln::channelmanager::ChannelManager::cancel_funding_contributed ManualIntervention, + /// The contribution is not valid given the current balances of the channel. + InvalidContribution(String), + /// A RBF is not available at this time. + RbfUnavailable(String), /// Internal error InternalError(&'static str), } @@ -209,6 +213,12 @@ impl Display for AbortReason { f.write_str("The initiator's feerate exceeds our maximum") }, AbortReason::ManualIntervention => f.write_str("Manually aborted funding negotiation"), + AbortReason::InvalidContribution(text) => { + f.write_fmt(format_args!("Invalid contribution: {}", text)) + }, + AbortReason::RbfUnavailable(text) => { + f.write_fmt(format_args!("Rejecting RBF attempt: {}", text)) + }, AbortReason::InternalError(text) => { f.write_fmt(format_args!("Internal error: {}", text)) }, diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index f480c4e9bc0..4d7e5173261 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -22,6 +22,7 @@ use crate::ln::channel::{ DISCONNECT_PEER_AWAITING_RESPONSE_TICKS, FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MIN_CHANNEL_VALUE_SATOSHIS, }; +use crate::ln::channel_state::{SpliceDetails, SpliceNegotiationStatus}; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; use crate::ln::funding::{FundingContribution, FundingContributionError, FundingTemplate}; @@ -174,23 +175,21 @@ fn config_with_min_funding_satoshis(min_funding_satoshis: u64) -> UserConfig { } #[cfg(test)] -fn assert_min_funding_error<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, min_funding_satoshis: u64) { - let msg_events = node.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1, "{msg_events:?}"); - match &msg_events[0] { - MessageSendEvent::HandleError { - action: msgs::ErrorAction::DisconnectPeerWithWarning { msg }, - .. - } => { - assert!( - msg.data - .contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")), - "unexpected warning: {}", - msg.data - ); - }, - _ => panic!("Expected HandleError with warning, got {:?}", msg_events[0]), - } +fn assert_min_funding_error<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, recipient: PublicKey, min_funding_satoshis: u64, +) { + let msg = get_event_msg!(node, MessageSendEvent::SendTxAbort, recipient); + let data = tx_abort_data(&msg); + assert!( + data.contains(&format!("configured min_funding_satoshis {min_funding_satoshis}")), + "unexpected tx_abort: {}", + data + ); +} + +#[cfg(test)] +fn tx_abort_data(msg: &msgs::TxAbort) -> String { + String::from_utf8(msg.data.clone()).expect("tx_abort data should be valid UTF-8") } pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( @@ -702,7 +701,6 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, funding_contribution: FundingContribution, ) -> (Transaction, ScriptBuf) { - let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); let new_funding_script = complete_splice_handshake(initiator, acceptor); @@ -718,7 +716,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); - expect_splice_pending_event(acceptor, &node_id_initiator); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); (splice_tx, new_funding_script) } @@ -1374,7 +1372,7 @@ fn test_min_funding_satoshis_rejects_splice_init_with_negative_counterparty_cont let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); assert!(splice_init.funding_contribution_satoshis < 0); nodes[1].node.handle_splice_init(node_id_0, &splice_init); - assert_min_funding_error(&nodes[1], min_funding_satoshis); + assert_min_funding_error(&nodes[1], node_id_0, min_funding_satoshis); } #[test] @@ -1472,7 +1470,7 @@ fn test_min_funding_satoshis_rejects_splice_ack_with_negative_counterparty_contr let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); assert!(splice_ack.funding_contribution_satoshis < 0); nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); - assert_min_funding_error(&nodes[0], min_funding_satoshis); + assert_min_funding_error(&nodes[0], node_id_1, min_funding_satoshis); } #[test] @@ -1514,7 +1512,7 @@ fn test_min_funding_satoshis_rejects_tx_init_rbf_with_negative_counterparty_cont let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1); assert!(tx_init_rbf.funding_output_contribution.unwrap() < 0); nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - assert_min_funding_error(&nodes[1], min_funding_satoshis); + assert_min_funding_error(&nodes[1], node_id_0, min_funding_satoshis); } #[test] @@ -1571,7 +1569,7 @@ fn test_min_funding_satoshis_rejects_tx_ack_rbf_with_negative_counterparty_contr let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0); assert!(tx_ack_rbf.funding_output_contribution.unwrap() < 0); nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf); - assert_min_funding_error(&nodes[0], min_funding_satoshis); + assert_min_funding_error(&nodes[0], node_id_1, min_funding_satoshis); } #[test] @@ -1749,7 +1747,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) { assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_1_id); - expect_splice_pending_event(&nodes[1], &node_0_id); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Now that the splice is pending, another splice may be initiated. assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id).is_ok()); @@ -2023,7 +2021,7 @@ fn do_test_splice_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); mine_transaction(&nodes[0], &tx); mine_transaction(&nodes[1], &tx); @@ -2070,7 +2068,7 @@ fn do_test_splice_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); - expect_splice_pending_event(&nodes[0], &node_id_1); + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); mine_transaction(&nodes[1], &new_splice_tx); mine_transaction(&nodes[0], &new_splice_tx); @@ -2536,7 +2534,7 @@ fn do_test_splice_reestablish(reload: bool, async_monitor_update: bool) { reconnect_nodes!(|reconnect_args: &mut ReconnectArgs| { reconnect_args.send_interactive_tx_sigs = (false, true); }); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Reestablish the channel again to make sure node 0 doesn't retransmit `tx_signatures` // unnecessarily as it was delivered in the previous reestablishment. @@ -2930,7 +2928,7 @@ fn test_splice_reestablish_waits_for_holder_tx_signatures_before_commitment_sign nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } #[test] @@ -3034,7 +3032,7 @@ fn test_splice_reestablish_sends_commitment_signed_before_tx_signatures() { nodes[1].node.handle_tx_signatures(node_id_0, &initiator_tx_signatures); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } #[test] @@ -4001,6 +3999,21 @@ fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() { .unwrap(); assert!(acceptor.node.get_and_clear_pending_msg_events().is_empty()); + // The acceptor is mid-negotiation on the counterparty's splice and has its own contribution + // queued behind it; both surface at once. + let details = acceptor + .node + .list_channels() + .into_iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .unwrap(); + assert_eq!(details.queued_contribution, Some(queued_contribution.clone())); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert!(!negotiation.is_initiator); + acceptor.node.cancel_funding_contributed(&channel_id, &node_id_initiator).unwrap(); let reason = NegotiationFailureReason::LocallyCanceled; expect_splice_failed_events(acceptor, &channel_id, queued_contribution, reason); @@ -4023,7 +4036,7 @@ fn acceptor_can_cancel_queued_funding_contributed_during_counterparty_splice() { let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false, None); assert!(splice_locked.is_none()); expect_splice_pending_event(initiator, &node_id_acceptor); - expect_splice_pending_event(acceptor, &node_id_initiator); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); mine_transaction(initiator, &splice_tx); mine_transaction(acceptor, &splice_tx); @@ -4422,7 +4435,7 @@ fn free_holding_cell_on_tx_signatures_quiescence_exit() { } expect_splice_pending_event(initiator, &node_id_acceptor); - expect_splice_pending_event(acceptor, &node_id_initiator); + assert!(acceptor.node.get_and_clear_pending_events().is_empty()); } #[test] @@ -4902,7 +4915,7 @@ fn test_splice_buffer_commitment_signed_until_funding_tx_signed() { } expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Both nodes should broadcast the splice transaction. let splice_tx = { @@ -5144,7 +5157,7 @@ fn do_splice_waits_for_initial_commitment_monitor_update_before_releasing_tx_sig expect_splice_pending_event(&nodes[0], &node_id_1); if !complete_update_while_disconnected { - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } } @@ -5808,13 +5821,14 @@ fn do_test_splice_pending_htlcs(config: UserConfig) { splice_init.funding_contribution_satoshis -= 1; acceptor.node.handle_splice_init(node_id_initiator, &splice_init); - let msg = get_warning_msg(acceptor, &node_id_initiator); + let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); assert_eq!(msg.channel_id, channel_id); let cannot_be_spliced_out = format!( - "Channel {} cannot be spliced out; their post-splice channel balance {} is smaller than our selected v2 reserve {}", - channel_id, post_splice_reserve - Amount::ONE_SAT, post_splice_reserve + "Their post-splice channel balance {} is smaller than our selected v2 reserve {}", + post_splice_reserve - Amount::ONE_SAT, + post_splice_reserve ); - assert_eq!(msg.data, cannot_be_spliced_out); + assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_be_spliced_out}")); acceptor.node.peer_disconnected(node_id_initiator); initiator.node.peer_disconnected(node_id_acceptor); @@ -6067,7 +6081,7 @@ fn test_splice_rbf_acceptor_basic() { 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_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; @@ -6116,7 +6130,7 @@ fn test_splice_rbf_acceptor_basic() { assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate. let result = lock_rbf_splice_after_blocks( @@ -6148,7 +6162,7 @@ fn test_splice_rbf_discard_unique_contribution() { 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_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; @@ -6217,7 +6231,7 @@ fn test_splice_rbf_discard_unique_contribution() { assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); let result = lock_rbf_splice_after_blocks( &nodes[0], @@ -6249,7 +6263,7 @@ fn test_splice_rbf_at_high_feerate() { 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_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; @@ -6284,7 +6298,7 @@ fn test_splice_rbf_at_high_feerate() { ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Step 3: RBF again using the template's min_rbf_feerate. The counterparty must accept it. provide_utxo_reserves(&nodes, 2, added_value * 2); @@ -6305,7 +6319,7 @@ fn test_splice_rbf_at_high_feerate() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(rbf_tx_1.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); } #[test] @@ -6506,7 +6520,7 @@ fn test_splice_rbf_insufficient_feerate_high() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // prev=1000: flat increment gives 1000+25=1025, 25/24 rule gives 1000*25/24=1041. // Feerate 1025 satisfies the flat increment but not 25/24 — rejected. @@ -6583,22 +6597,11 @@ fn test_splice_rbf_no_pending_splice() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - match &msg_events[0] { - MessageSendEvent::HandleError { action, .. } => { - assert_eq!( - *action, - msgs::ErrorAction::DisconnectPeerWithWarning { - msg: msgs::WarningMessage { - channel_id, - data: format!("Channel {} has no pending splice to RBF", channel_id), - }, - } - ); - }, - _ => panic!("Expected HandleError, got {:?}", msg_events[0]), - } + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!( + tx_abort_data(&tx_abort), + "Rejecting RBF attempt: No pending splice available to RBF" + ); } #[test] @@ -6696,25 +6699,8 @@ fn test_splice_rbf_after_splice_locked() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - match &msg_events[0] { - MessageSendEvent::HandleError { action, .. } => { - assert_eq!( - *action, - msgs::ErrorAction::DisconnectPeerWithWarning { - msg: msgs::WarningMessage { - channel_id, - data: format!( - "Channel {} counterparty already sent splice_locked, cannot RBF", - channel_id, - ), - }, - } - ); - }, - _ => panic!("Expected HandleError, got {:?}", msg_events[0]), - } + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!(tx_abort_data(&tx_abort), "Rejecting RBF attempt: Already received splice_locked"); } #[test] @@ -6897,22 +6883,11 @@ fn test_splice_rbf_zeroconf_rejected() { nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf); - let msg_events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - match &msg_events[0] { - MessageSendEvent::HandleError { action, .. } => { - assert_eq!( - *action, - msgs::ErrorAction::DisconnectPeerWithWarning { - msg: msgs::WarningMessage { - channel_id, - data: format!("Channel {} has option_zeroconf, cannot RBF", channel_id,), - }, - } - ); - }, - _ => panic!("Expected HandleError, got {:?}", msg_events[0]), - } + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0); + assert_eq!( + tx_abort_data(&tx_abort), + format!("Rejecting RBF attempt: Channel {} has option_zeroconf, cannot RBF", channel_id) + ); } #[test] @@ -7218,7 +7193,7 @@ pub fn do_test_splice_rbf_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Mine, lock, and verify DiscardFunding for the replaced splice candidate. // Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates @@ -7281,7 +7256,7 @@ pub fn do_test_splice_rbf_tiebreak( assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[1], &node_id_0); - expect_splice_pending_event(&nodes[0], &node_id_1); + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); // Mine and lock. mine_transaction(&nodes[1], &new_splice_tx); @@ -7780,7 +7755,7 @@ fn test_splice_rbf_sequential() { 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_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; @@ -7818,7 +7793,7 @@ fn test_splice_rbf_sequential() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_0.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // --- Round 2: RBF #2 at feerate 303. --- provide_utxo_reserves(&nodes, 2, added_value * 2); @@ -7839,7 +7814,7 @@ fn test_splice_rbf_sequential() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx_1.compute_txid())); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. --- let splice_tx_0_txid = splice_tx_0.compute_txid(); @@ -7865,7 +7840,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { 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_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, channel_id, _) = @@ -7908,7 +7883,7 @@ fn test_splice_rbf_amends_prior_net_positive_contribution_request() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); tx }; @@ -7997,7 +7972,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { 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_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); let (_, _, channel_id, _) = @@ -8042,7 +8017,7 @@ fn test_splice_rbf_amends_prior_net_negative_contribution_request() { sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(replaced_txid)); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); tx }; @@ -8228,23 +8203,13 @@ fn test_splice_rbf_acceptor_contributes_then_disconnects() { // The initiator re-used the same UTXOs as round 0. Since those UTXOs are still committed // to round 0's splice, they are filtered and no DiscardFunding is emitted. - let events = nodes[0].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1, "{events:?}"); - match &events[0] { - Event::SpliceNegotiationFailed { channel_id: cid, reason, contribution, .. } => { - assert_eq!(*cid, channel_id); - assert_eq!(*reason, NegotiationFailureReason::PeerDisconnected); - assert!(contribution.is_some()); - }, - other => panic!("Expected SpliceNegotiationFailed, got {:?}", other), - } + let _ = get_event!(&nodes[0], Event::SpliceNegotiationFailed); // The acceptor re-contributed the same UTXOs as round 0 (via prior contribution // adjustment). Since those UTXOs are still committed to round 0's splice, they are - // filtered and no DiscardFunding is emitted. With all inputs/outputs filtered, no events - // are emitted for the acceptor. - let events = nodes[1].node.get_and_clear_pending_events(); - assert_eq!(events.len(), 0, "{events:?}"); + // filtered and no DiscardFunding is emitted. The contribution still fails and needs a + // SpliceNegotiationFailed event so the wallet can resume funding. + let _ = get_event!(&nodes[1], Event::SpliceNegotiationFailed); // Reconnect. let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); @@ -9088,7 +9053,7 @@ fn test_splice_rbf_rejects_low_feerate_after_several_attempts() { ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); prev_feerate = feerate; prev_splice_tx = rbf_tx; } @@ -9120,7 +9085,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { 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_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; @@ -9163,7 +9128,7 @@ fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() { ); assert!(splice_locked.is_none()); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); prev_feerate = feerate; prev_splice_tx = rbf_tx; } @@ -9233,10 +9198,10 @@ fn test_no_disconnect_after_splice_completes() { let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); assert!(splice_locked.is_none()); - let node_id_0 = nodes[0].node.get_our_node_id(); + let _node_id_0 = nodes[0].node.get_our_node_id(); let node_id_1 = nodes[1].node.get_our_node_id(); expect_splice_pending_event(&nodes[0], &node_id_1); - expect_splice_pending_event(&nodes[1], &node_id_0); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); // Fire enough ticks to trigger a disconnect if the timer wasn't properly cleared. for _ in 0..DISCONNECT_PEER_AWAITING_RESPONSE_TICKS { @@ -9728,40 +9693,35 @@ fn do_test_0reserve_splice_counterparty_validation( get_event_msg!(acceptor, MessageSendEvent::SendSpliceAck, node_id_initiator); } else { acceptor.node.handle_splice_init(node_id_initiator, &splice_init); - let msg_events = acceptor.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { - assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); - } else { - panic!("Expected MessageSendEvent::HandleError"); - } + let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + assert_eq!(msg.channel_id, channel_id); let cannot_splice_out = if u64::try_from(funding_contribution_sat.abs()).unwrap() > initiator_value_to_self_sat { // They obviously can't afford their contribution, so we fail before even // querying `TxBuilder` format!( - "Got non-closing error: Channel {channel_id} cannot be spliced; \ - Their contribution candidate {funding_contribution_sat}sat \ + "Their contribution candidate {funding_contribution_sat}sat \ is greater than their total balance in the channel {initiator_value_to_self_sat}sat" ) } else if post_channel_value_sat < MIN_CHANNEL_VALUE_SATOSHIS { // We require all spliced channels to have a value of at least 1000 satoshis after the splice format!( - "Got non-closing error: Channel {channel_id} cannot be spliced; \ - Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ + "Spliced channel value must be at least {MIN_CHANNEL_VALUE_SATOSHIS} satoshis. \ It would be {post_channel_value_sat}" ) } else { // Last but not least, `TxBuilder` decides whether all parties can afford // HTLCs, anchors, and transaction fees while retaining at least one // output on the commitments - format!( - "Got non-closing error: Channel {channel_id} cannot \ - be spliced; Balance exhausted on local commitment" - ) + "Balance exhausted on local commitment".to_string() }; - acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_splice_out}")); + acceptor.logger.assert_log( + "lightning::ln::channelmanager", + format!("Got non-closing error: Invalid contribution: {cannot_splice_out}"), + 1, + ); } channel_type @@ -10002,18 +9962,12 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( // balance, we previously would not complain. splice_init.funding_contribution_satoshis = funding_contribution_sat; acceptor.node.handle_splice_init(node_id_initiator, &splice_init); - let msg_events = acceptor.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - if let MessageSendEvent::HandleError { action, .. } = &msg_events[0] { - assert!(matches!(action, msgs::ErrorAction::DisconnectPeerWithWarning { .. })); - } else { - panic!("Expected MessageSendEvent::HandleError"); - } + let msg = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); + assert_eq!(msg.channel_id, channel_id); let post_splice_channel_value_sat = node_0_balance_leftover_amount.to_sat(); let cannot_splice_out = if matches!(acceptor_balance, AcceptorBalance::NoBalance) { format!( - "Got non-closing error: Channel {channel_id} cannot \ - be spliced; The post-splice channel value {post_splice_channel_value_sat} \ + "The post-splice channel value {post_splice_channel_value_sat} \ is smaller than their dust limit {high_dust_limit_satoshis}" ) } else { @@ -10026,13 +9980,17 @@ fn do_test_splice_out_initiator_reserve_breach_zero_fee_commitments( high_dust_limit_satoshis ); format!( - "Got non-closing error: Channel {channel_id} cannot \ - be spliced out; their post-splice channel balance \ + "Their post-splice channel balance \ {node_0_balance_leftover_amount} is smaller than our selected v2 reserve \ {v2_channel_reserve}" ) }; - acceptor.logger.assert_log("lightning::ln::channelmanager", cannot_splice_out, 1); + assert_eq!(tx_abort_data(&msg), format!("Invalid contribution: {cannot_splice_out}")); + acceptor.logger.assert_log( + "lightning::ln::channelmanager", + format!("Got non-closing error: Invalid contribution: {cannot_splice_out}"), + 1, + ); } } @@ -10248,3 +10206,356 @@ fn test_splice_out_maximum_includes_pending_claimed_inbound_htlc() { assert!(nodes[1].node.splice_channel(&channel_id, &node_id_0).is_ok()); } + +#[test] +fn test_channel_details_pending_splice() { + // Test that `ChannelDetails::splice_details` reflects pending splice state throughout + // negotiation, signing, RBF, restarts, and locking. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let (persister_0, persister_1); + let (chain_monitor_0, chain_monitor_1); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let (node_0, node_1); + let mut 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 splice_details = |node: &Node<'_, '_, '_>| { + node.node + .list_channels() + .iter() + .find(|channel| channel.channel_id == channel_id) + .unwrap() + .splice_details + .clone() + }; + + // No splice is pending yet. + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); + + let added_value = Amount::from_sat(50_000); + provide_utxo_reserves(&nodes, 2, added_value * 2); + + // Contributing funds queues the contribution but does not start the negotiation; that begins + // once the channel becomes quiescent and splice_init is sent. Until then it surfaces as a + // queued contribution with no negotiation. + let contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value); + assert_eq!( + splice_details(&nodes[0]), + Some(SpliceDetails { + negotiation: None, + queued_contribution: Some(contribution.clone()), + candidates: vec![], + confirmed_candidate: None, + received_splice_locked_txid: None, + }), + ); + assert_eq!(splice_details(&nodes[1]), None); + + let new_channel_value_sat = + (initial_channel_value_sat as i64 + contribution.net_value().to_sat()) as u64; + + let stfu_init = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + nodes[1].node.handle_stfu(node_id_0, &stfu_init); + let stfu_ack = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0); + nodes[0].node.handle_stfu(node_id_1, &stfu_ack); + + // Once quiescent, the initiator sends splice_init and awaits the counterparty's splice_ack. + // The new channel value is not yet known as it depends on the counterparty's contribution. + let details = splice_details(&nodes[0]).unwrap(); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::AwaitingAck); + assert!(negotiation.is_initiator); + assert_eq!(negotiation.funding_feerate_sat_per_1000_weight, FEERATE_FLOOR_SATS_PER_KW); + assert_eq!(negotiation.new_channel_value_satoshis, None); + assert_eq!(negotiation.txid, None); + assert_eq!(negotiation.contribution, Some(contribution.clone())); + assert!(details.candidates.is_empty()); + assert_eq!(splice_details(&nodes[1]), None); + + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); + nodes[1].node.handle_splice_init(node_id_0, &splice_init); + + // The acceptor starts constructing the transaction upon receiving splice_init, at which + // point both contributions are known. + let details = splice_details(&nodes[1]).unwrap(); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert!(!negotiation.is_initiator); + assert_eq!(negotiation.funding_feerate_sat_per_1000_weight, FEERATE_FLOOR_SATS_PER_KW); + assert_eq!(negotiation.new_channel_value_satoshis, Some(new_channel_value_sat)); + assert_eq!(negotiation.txid, None); + assert_eq!(negotiation.contribution, None); + assert!(details.candidates.is_empty()); + + let splice_ack = get_event_msg!(nodes[1], MessageSendEvent::SendSpliceAck, node_id_0); + nodes[0].node.handle_splice_ack(node_id_1, &splice_ack); + + let negotiation = splice_details(&nodes[0]).unwrap().negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert!(negotiation.is_initiator); + assert_eq!(negotiation.new_channel_value_satoshis, Some(new_channel_value_sat)); + assert_eq!(negotiation.txid, None); + + let new_funding_script = chan_utils::make_funding_redeemscript( + &splice_init.funding_pubkey, + &splice_ack.funding_pubkey, + ) + .to_p2wsh(); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + contribution.clone(), + new_funding_script.clone(), + ); + + // Once construction completes, the negotiation awaits signatures and the txid is known. + let negotiation_0 = splice_details(&nodes[0]).unwrap().negotiation.unwrap(); + let negotiation_1 = splice_details(&nodes[1]).unwrap().negotiation.unwrap(); + assert_eq!(negotiation_0.status, SpliceNegotiationStatus::AwaitingSignatures); + assert_eq!(negotiation_1.status, SpliceNegotiationStatus::AwaitingSignatures); + assert!(negotiation_0.txid.is_some()); + assert_eq!(negotiation_0.txid, negotiation_1.txid); + assert_eq!(negotiation_0.new_channel_value_satoshis, Some(new_channel_value_sat)); + assert_eq!(negotiation_0.contribution, Some(contribution.clone())); + assert_eq!(negotiation_1.contribution, None); + + let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false, None); + assert!(splice_locked.is_none()); + assert_eq!(negotiation_0.txid, Some(splice_tx.compute_txid())); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // With signatures exchanged, the negotiated splice is a candidate awaiting confirmations. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].new_channel_value_satoshis, new_channel_value_sat); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(details.confirmed_candidate, None); + assert_eq!(details.received_splice_locked_txid, None); + + // The acceptor did not contribute to the splice. + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, None); + + // Initiate an RBF attempt at a higher feerate. + provide_utxo_reserves(&nodes, 2, added_value * 2); + let rbf_feerate_sat_per_kwu = FEERATE_FLOOR_SATS_PER_KW as u64 + 25; + let rbf_feerate = FeeRate::from_sat_per_kwu(rbf_feerate_sat_per_kwu); + let rbf_contribution = do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, rbf_feerate); + + // The RBF contribution is queued behind the still-pending original candidate until quiescence + // is re-reached; until then it surfaces as a queued contribution with no negotiation. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.queued_contribution, Some(rbf_contribution.clone())); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + + complete_rbf_handshake(&nodes[0], &nodes[1]); + + // The RBF negotiation is reported alongside the still-pending original candidate. + let details = splice_details(&nodes[0]).unwrap(); + let negotiation = details.negotiation.unwrap(); + assert_eq!(negotiation.status, SpliceNegotiationStatus::ConstructingTransaction); + assert_eq!(negotiation.funding_feerate_sat_per_1000_weight, rbf_feerate_sat_per_kwu as u32); + assert_eq!(negotiation.contribution, Some(rbf_contribution.clone())); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + + let rbf_channel_value_sat = + (initial_channel_value_sat as i64 + rbf_contribution.net_value().to_sat()) as u64; + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + rbf_contribution.clone(), + new_funding_script, + ); + let (rbf_tx, splice_locked) = + sign_interactive_funding_tx(&nodes[0], &nodes[1], false, Some(splice_tx.compute_txid())); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_id_1); + expect_splice_pending_event(&nodes[1], &node_id_0); + + // Both the original splice and its RBF replacement are candidates, in negotiation order. + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution.clone())); + assert_eq!(details.candidates[1].txid, rbf_tx.compute_txid()); + assert_eq!(details.candidates[1].new_channel_value_satoshis, rbf_channel_value_sat); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution.clone())); + + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[1].contribution, None); + + // Pending splice state, including per-candidate contributions, survives a restart. + 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 encoded_monitor_1 = get_monitor!(nodes[1], channel_id).encode(); + reload_node!( + nodes[1], + &nodes[1].node.encode(), + &[&encoded_monitor_1], + persister_1, + chain_monitor_1, + node_1 + ); + + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates.len(), 2); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, Some(contribution)); + assert_eq!(details.candidates[1].txid, rbf_tx.compute_txid()); + assert_eq!(details.candidates[1].contribution, Some(rbf_contribution)); + + let details = splice_details(&nodes[1]).unwrap(); + assert_eq!(details.candidates.len(), 2); + assert!(details.candidates.iter().all(|candidate| candidate.contribution.is_none())); + + let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); + reconnect_args.send_announcement_sigs = (true, true); + reconnect_nodes(reconnect_args); + + // Mine the RBF transaction; only its candidate confirms, identified by its index. + mine_transaction(&nodes[0], &rbf_tx); + mine_transaction(&nodes[1], &rbf_tx); + + let details = splice_details(&nodes[0]).unwrap(); + let confirmed = details.confirmed_candidate.unwrap(); + assert_eq!(confirmed.txid, rbf_tx.compute_txid()); + assert_eq!(confirmed.confirmations, 1); + assert_eq!(confirmed.confirmations_required, 6); + + connect_blocks(&nodes[0], ANTI_REORG_DELAY - 1); + connect_blocks(&nodes[1], ANTI_REORG_DELAY - 1); + + // Once sufficiently confirmed, the splice_locked we sent is reflected in the details until + // the counterparty's splice_locked is received and the splice is promoted. + let splice_locked = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_1); + let details = splice_details(&nodes[0]).unwrap(); + assert_eq!(details.received_splice_locked_txid, None); + let confirmed = details.confirmed_candidate.unwrap(); + assert_eq!(confirmed.txid, rbf_tx.compute_txid()); + assert!(confirmed.splice_locked_sent); + assert_eq!(confirmed.confirmations, ANTI_REORG_DELAY); + + lock_splice(&nodes[0], &nodes[1], &splice_locked, false, &[splice_tx.compute_txid()]); + + // The splice is no longer pending once promoted. + assert_eq!(splice_details(&nodes[0]), None); + assert_eq!(splice_details(&nodes[1]), None); +} + +#[test] +fn test_channel_details_first_contribution_on_rbf() { + // When the counterparty's splice did not include a contribution from us and our first + // contribution comes in an RBF round we initiate, the in-flight contribution must not be + // attributed to the negotiated counterparty-only candidate. + 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, added_value * 2); + + // Splice initiated by node 1; node 0 does not contribute. + let contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value); + let (splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, contribution); + + // Node 0 initiates an RBF, contributing for the first time. + let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25); + let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).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]); + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendTxAddInput, node_id_1); + + // While the RBF is being negotiated, node 0's contribution belongs to the negotiation, not + // to the negotiated counterparty-only candidate. + let channels = nodes[0].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.negotiation.as_ref().unwrap().contribution, Some(rbf_contribution.clone())); + assert_eq!(details.candidates.len(), 1); + assert_eq!(details.candidates[0].txid, splice_tx.compute_txid()); + assert_eq!(details.candidates[0].contribution, None); + + // Node 1 adjusted its prior contribution for the RBF round; the negotiated candidate keeps + // its original contribution. + let channels = nodes[1].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert!(details.negotiation.as_ref().unwrap().contribution.is_some()); + assert!(details.candidates[0].contribution.is_some()); + + // Abort the negotiation via disconnect. + nodes[0].node.peer_disconnected(node_id_1); + nodes[1].node.peer_disconnected(node_id_0); + + expect_splice_failed_events( + &nodes[0], + &channel_id, + rbf_contribution, + NegotiationFailureReason::PeerDisconnected, + ); + // Node 1 did not initiate the RBF round and its contribution to it (the prior round's + // contribution adjusted to the new feerate) has no inputs or outputs unique from the prior + // round, so no events are emitted. + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + + // After the reset, the contribution alignment is restored on both nodes. + let channels = nodes[0].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.negotiation, None); + assert_eq!(details.candidates[0].contribution, None); + let channels = nodes[1].node.list_channels(); + let details = channels[0].splice_details.as_ref().unwrap(); + assert_eq!(details.negotiation, None); + assert!(details.candidates[0].contribution.is_some()); +} diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index b9b7a93877a..59804545381 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -4249,6 +4249,7 @@ mod tests { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, } } @@ -9813,6 +9814,7 @@ pub(crate) mod bench_utils { pending_inbound_htlcs: Vec::new(), pending_outbound_htlcs: Vec::new(), current_dust_exposure_msat: None, + splice_details: None, } } diff --git a/pending_changelog/4687-pending-splice-details.txt b/pending_changelog/4687-pending-splice-details.txt new file mode 100644 index 00000000000..789d05c3a0a --- /dev/null +++ b/pending_changelog/4687-pending-splice-details.txt @@ -0,0 +1,18 @@ +# API Updates + + * `ChannelDetails` now has a `splice_details` field + (`Option`) reporting any pending splice attempts on a channel: + a contribution committed via `ChannelManager::funding_contributed` but not yet + negotiating, the in-flight negotiation (`SpliceNegotiationDetails`, with + progress given by `SpliceNegotiationStatus`), the negotiated candidates + awaiting confirmation (`SpliceCandidateDetails`), the confirmed candidate's + progress (`ConfirmedSpliceCandidate`), and the txid of any `splice_locked` + received from the counterparty. + +# Backwards Compatibility + + * A channel with a pending splice negotiated before upgrading from a prior LDK + version (e.g. 0.2) cannot be RBF'ed: `ChannelManager::splice_channel` + now returns an `APIError::APIMisuseError`, as the prior feerate and our + contribution needed to derive the RBF feerate floor are not persisted by + older versions.