From 05c9a036313ee34847a44c713b77d76fddc775be Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Sun, 1 Feb 2026 01:30:13 +0000 Subject: [PATCH 01/21] Add a `custom` TLV read/write variant At various points we've been stuck in our TLV read/write variants but just want to break out and write some damn code to initialize a field and some more code to decide what to write for a TLV. We added the write-side part of this with the `legacy` TLV read/write variant, but its useful to also be able to specify a function which is called on the read side. Here we add a `custom` TLV read/write variant which calls a method both on read and write to either decide what to write or to map a read value (if any) to the final field. --- lightning-macros/src/lib.rs | 6 ++--- lightning/src/util/ser_macros.rs | 43 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/lightning-macros/src/lib.rs b/lightning-macros/src/lib.rs index e784acf72fb..778da45ee8f 100644 --- a/lightning-macros/src/lib.rs +++ b/lightning-macros/src/lib.rs @@ -138,7 +138,7 @@ fn process_fields(group: Group) -> proc_macro::TokenStream { if let TokenTree::Group(group) = ty_info { let first_group_tok = group.stream().into_iter().next().unwrap(); if let TokenTree::Ident(ident) = first_group_tok { - if ident.to_string() == "legacy" { + if ident.to_string() == "legacy" || ident.to_string() == "custom" { continue; } } @@ -155,13 +155,13 @@ fn process_fields(group: Group) -> proc_macro::TokenStream { computed_fields } -/// Scans a match statement for legacy fields which should be skipped. +/// Scans a match statement for legacy or custom fields which should be skipped. /// /// This is used internally in LDK's TLV serialization logic and is not expected to be used by /// other crates. /// /// Wraps a `match self {..}` statement and scans the fields in the match patterns (in the form -/// `ref $field_name: $field_ty`) for types marked `legacy`, skipping those fields. +/// `ref $field_name: $field_ty`) for types marked `legacy` or `custom`, skipping those fields. /// /// Specifically, it expects input like the following, simply dropping `field3` and the /// `: $field_ty` after each field name. diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index 86b24e1b849..bd2b5d1983a 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -63,6 +63,9 @@ macro_rules! _encode_tlv { } $crate::_encode_tlv!($stream, $optional_type, value, option); } }; + ($stream: expr, $optional_type: expr, $optional_field: expr, (custom, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => { { + $crate::_encode_tlv!($stream, $optional_type, $optional_field, (legacy, $fieldty, $write) $(, $self)?); + } }; ($stream: expr, $type: expr, $field: expr, optional_vec $(, $self: ident)?) => { if !$field.is_empty() { $crate::_encode_tlv!($stream, $type, $field, required_vec); @@ -232,6 +235,9 @@ macro_rules! _get_varint_length_prefixed_tlv_length { ($len: expr, $optional_type: expr, $optional_field: expr, (legacy, $fieldty: ty, $write: expr) $(, $self: ident)?) => { $crate::_get_varint_length_prefixed_tlv_length!($len, $optional_type, $write($($self)?), option); }; + ($len: expr, $optional_type: expr, $optional_field: expr, (custom, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => { + $crate::_get_varint_length_prefixed_tlv_length!($len, $optional_type, $optional_field, (legacy, $fieldty, $write) $(, $self)?); + }; ($len: expr, $type: expr, $field: expr, optional_vec $(, $self: ident)?) => { if !$field.is_empty() { $crate::_get_varint_length_prefixed_tlv_length!($len, $type, $field, required_vec); @@ -317,6 +323,16 @@ macro_rules! _check_decoded_tlv_order { ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (legacy, $fieldty: ty, $write: expr)) => {{ // no-op }}; + ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (custom, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => {{ + // Note that $type may be 0 making the second comparison always false + #[allow(unused_comparisons)] + let invalid_order = + ($last_seen_type.is_none() || $last_seen_type.unwrap() < $type) && $typ.0 > $type; + if invalid_order { + let read_result: Result<_, DecodeError> = $read(None); + $field = read_result?.into(); + } + }}; ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (required, explicit_type: $fieldty: ty)) => {{ _check_decoded_tlv_order!($last_seen_type, $typ, $type, $field, required); }}; @@ -385,6 +401,15 @@ macro_rules! _check_missing_tlv { ($last_seen_type: expr, $type: expr, $field: ident, (legacy, $fieldty: ty, $write: expr)) => {{ // no-op }}; + ($last_seen_type: expr, $type: expr, $field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => {{ + // Note that $type may be 0 making the second comparison always false + #[allow(unused_comparisons)] + let missing_req_type = $last_seen_type.is_none() || $last_seen_type.unwrap() < $type; + if missing_req_type { + let read_result: Result<_, DecodeError> = $read(None); + $field = read_result?.into(); + } + }}; ($last_seen_type: expr, $type: expr, $field: ident, (required, explicit_type: $fieldty: ty)) => {{ _check_missing_tlv!($last_seen_type, $type, $field, required); }}; @@ -441,6 +466,12 @@ macro_rules! _decode_tlv { ($outer_reader: expr, $reader: expr, $field: ident, (legacy, $fieldty: ty, $write: expr)) => {{ $crate::_decode_tlv!($outer_reader, $reader, $field, (option, explicit_type: $fieldty)); }}; + ($outer_reader: expr, $reader: expr, $field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => {{ + let read_field: $fieldty; + $crate::_decode_tlv!($outer_reader, $reader, read_field, required); + let read_result: Result<_, DecodeError> = $read(Some(read_field)); + $field = read_result?.into(); + }}; ($outer_reader: expr, $reader: expr, $field: ident, (required, explicit_type: $fieldty: ty)) => {{ let _field: &$fieldty = &$field; _decode_tlv!($outer_reader, $reader, $field, required); @@ -830,6 +861,9 @@ macro_rules! _init_tlv_based_struct_field { ($field: ident, (legacy, $fieldty: ty, $write: expr)) => { $crate::_init_tlv_based_struct_field!($field, option) }; + ($field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => { + $crate::_init_tlv_based_struct_field!($field, required) + }; ($field: ident, (option: $trait: ident $(, $read_arg: expr)?)) => { $crate::_init_tlv_based_struct_field!($field, option) }; @@ -896,6 +930,9 @@ macro_rules! _init_tlv_field_var { ($field: ident, (legacy, $fieldty: ty, $write: expr)) => { $crate::_init_tlv_field_var!($field, (option, explicit_type: $fieldty)); }; + ($field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => { + $crate::_init_tlv_field_var!($field, required); + }; ($field: ident, (required, explicit_type: $fieldty: ty)) => { let mut $field = $crate::util::ser::RequiredWrapper::<$fieldty>(None); }; @@ -979,6 +1016,12 @@ macro_rules! _decode_and_build { /// called with the object being serialized and a returned `Option` and is written as a TLV if /// `Some`. When reading, an optional field of type `$ty` is read (which can be used in later /// `default_value` or `static_value` fields by referring to the value by name). +/// If `$fieldty` is `(custom, $ty, $read, $write)` then, when writing, the same behavior as +/// `legacy`, above is used. When reading, if a TLV is present, it is read as `$ty` and the +/// `$read` method is called with `Some(decoded_$ty_object)`. If no TLV is present, the field +/// will be initialized by calling `$read(None)`. `$read` should return a +/// `Result` (note that the processed field type may differ from `$ty`; +/// `$ty` is the type as de/serialized, not necessarily the actual field type). /// /// For example, /// ``` From 49c80a3d9087ef905b2ea0fe18a78729cedd178f Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 5 Feb 2026 15:28:50 -0600 Subject: [PATCH 02/21] Add a read closure to the `legacy` TLV variant Update the `legacy` TLV read/write variant signature from `(legacy, $fieldty, $write)` to `(legacy, $fieldty, $read, $write)`, adding a read closure parameter matching the `custom` variant's signature. The read closure is applied in `_check_missing_tlv!` after all TLV fields are read but before `static_value` fields consume legacy values. This preserves backwards compatibility with `static_value` and `default_value` expressions that reference legacy field variables as `Option<$fieldty>` during TLV reading. The read closure signature matches `custom`: `FnOnce(Option<$fieldty>) -> Result, DecodeError>`. All existing usage sites use `Ok` as their read closure (identity/ no-op). Co-Authored-By: Claude Opus 4.6 --- lightning/src/chain/package.rs | 2 +- lightning/src/ln/channel_state.rs | 6 ++--- lightning/src/ln/onion_utils.rs | 6 ++--- lightning/src/ln/outbound_payment.rs | 6 ++--- lightning/src/util/ser_macros.rs | 34 ++++++++++++++++------------ 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/lightning/src/chain/package.rs b/lightning/src/chain/package.rs index 0abe3534341..0ef8855242b 100644 --- a/lightning/src/chain/package.rs +++ b/lightning/src/chain/package.rs @@ -183,7 +183,7 @@ impl_writeable_tlv_based!(RevokedOutput, { (12, on_counterparty_tx_csv, required), // Unused since 0.1, this setting causes downgrades to before 0.1 to refuse to // aggregate `RevokedOutput` claims, which is the more conservative stance. - (14, is_counterparty_balance_on_anchors, (legacy, (), |_| Some(()))), + (14, is_counterparty_balance_on_anchors, (legacy, (), |_| Ok(()), |_| Some(()))), (15, channel_parameters, (option: ReadableArgs, None)), // Added in 0.2. }); diff --git a/lightning/src/ln/channel_state.rs b/lightning/src/ln/channel_state.rs index 86e53ba3262..eda79e03308 100644 --- a/lightning/src/ln/channel_state.rs +++ b/lightning/src/ln/channel_state.rs @@ -607,9 +607,9 @@ impl_writeable_tlv_based!(ChannelDetails, { (10, channel_value_satoshis, required), (12, unspendable_punishment_reserve, option), // Note that _user_channel_id_low is used below, but rustc warns anyway - (14, _user_channel_id_low, (legacy, u64, + (14, _user_channel_id_low, (legacy, u64, |_| Ok(()), |us: &ChannelDetails| Some(us.user_channel_id as u64))), - (16, _balance_msat, (legacy, u64, |us: &ChannelDetails| Some(us.next_outbound_htlc_limit_msat))), + (16, _balance_msat, (legacy, u64, |_| Ok(()), |us: &ChannelDetails| Some(us.next_outbound_htlc_limit_msat))), (18, outbound_capacity_msat, required), (19, next_outbound_htlc_limit_msat, (default_value, outbound_capacity_msat)), (20, inbound_capacity_msat, required), @@ -623,7 +623,7 @@ impl_writeable_tlv_based!(ChannelDetails, { (33, inbound_htlc_minimum_msat, option), (35, inbound_htlc_maximum_msat, option), // Note that _user_channel_id_high is used below, but rustc warns anyway - (37, _user_channel_id_high, (legacy, u64, + (37, _user_channel_id_high, (legacy, u64, |_| Ok(()), |us: &ChannelDetails| Some((us.user_channel_id >> 64) as u64))), (39, feerate_sat_per_1000_weight, option), (41, channel_shutdown_state, option), diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index d48fcb25179..605f27e9666 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1943,14 +1943,14 @@ impl Readable for HTLCFailReason { impl_writeable_tlv_based_enum!(HTLCFailReasonRepr, (0, LightningError) => { - (0, data, (legacy, Vec, |us| + (0, data, (legacy, Vec, |_| Ok(()), |us| if let &HTLCFailReasonRepr::LightningError { err: msgs::OnionErrorPacket { ref data, .. }, .. } = us { Some(data) } else { None }) ), - (1, attribution_data, (legacy, AttributionData, |us| + (1, attribution_data, (legacy, AttributionData, |_| Ok(()), |us| if let &HTLCFailReasonRepr::LightningError { err: msgs::OnionErrorPacket { ref attribution_data, .. }, .. } = us { attribution_data.as_ref() } else { @@ -1961,7 +1961,7 @@ impl_writeable_tlv_based_enum!(HTLCFailReasonRepr, (_unused, err, (static_value, msgs::OnionErrorPacket { data: data.ok_or(DecodeError::InvalidValue)?, attribution_data })), }, (1, Reason) => { - (0, _failure_code, (legacy, u16, + (0, _failure_code, (legacy, u16, |_| Ok(()), |r: &HTLCFailReasonRepr| match r { HTLCFailReasonRepr::LightningError{ .. } => None, HTLCFailReasonRepr::Reason{ failure_reason, .. } => Some(failure_reason.failure_code()) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index ea33bb5d263..170e4e13830 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2731,7 +2731,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (5, AwaitingInvoice) => { (0, expiration, required), (2, retry_strategy, required), - (4, _max_total_routing_fee_msat, (legacy, u64, + (4, _max_total_routing_fee_msat, (legacy, u64, |_| Ok(()), |us: &PendingOutboundPayment| match us { PendingOutboundPayment::AwaitingInvoice { route_params_config, .. } => route_params_config.max_total_routing_fee_msat, _ => None, @@ -2748,7 +2748,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (7, InvoiceReceived) => { (0, payment_hash, required), (2, retry_strategy, required), - (4, _max_total_routing_fee_msat, (legacy, u64, + (4, _max_total_routing_fee_msat, (legacy, u64, |_| Ok(()), |us: &PendingOutboundPayment| match us { PendingOutboundPayment::InvoiceReceived { route_params_config, .. } => route_params_config.max_total_routing_fee_msat, _ => None, @@ -2779,7 +2779,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (11, AwaitingOffer) => { (0, expiration, required), (2, retry_strategy, required), - (4, _max_total_routing_fee_msat, (legacy, u64, + (4, _max_total_routing_fee_msat, (legacy, u64, |_| Ok(()), |us: &PendingOutboundPayment| match us { PendingOutboundPayment::AwaitingOffer { route_params_config, .. } => route_params_config.max_total_routing_fee_msat, _ => None, diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index bd2b5d1983a..5342465cb96 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -45,7 +45,7 @@ macro_rules! _encode_tlv { field.write($stream)?; } }; - ($stream: expr, $optional_type: expr, $optional_field: expr, (legacy, $fieldty: ty, $write: expr) $(, $self: ident)?) => { { + ($stream: expr, $optional_type: expr, $optional_field: expr, (legacy, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => { { let value: Option<_> = $write($($self)?); #[cfg(debug_assertions)] { @@ -64,7 +64,7 @@ macro_rules! _encode_tlv { $crate::_encode_tlv!($stream, $optional_type, value, option); } }; ($stream: expr, $optional_type: expr, $optional_field: expr, (custom, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => { { - $crate::_encode_tlv!($stream, $optional_type, $optional_field, (legacy, $fieldty, $write) $(, $self)?); + $crate::_encode_tlv!($stream, $optional_type, $optional_field, (legacy, $fieldty, $read, $write) $(, $self)?); } }; ($stream: expr, $type: expr, $field: expr, optional_vec $(, $self: ident)?) => { if !$field.is_empty() { @@ -232,11 +232,11 @@ macro_rules! _get_varint_length_prefixed_tlv_length { $len.0 += field_len; } }; - ($len: expr, $optional_type: expr, $optional_field: expr, (legacy, $fieldty: ty, $write: expr) $(, $self: ident)?) => { + ($len: expr, $optional_type: expr, $optional_field: expr, (legacy, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => { $crate::_get_varint_length_prefixed_tlv_length!($len, $optional_type, $write($($self)?), option); }; ($len: expr, $optional_type: expr, $optional_field: expr, (custom, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => { - $crate::_get_varint_length_prefixed_tlv_length!($len, $optional_type, $optional_field, (legacy, $fieldty, $write) $(, $self)?); + $crate::_get_varint_length_prefixed_tlv_length!($len, $optional_type, $optional_field, (legacy, $fieldty, $read, $write) $(, $self)?); }; ($len: expr, $type: expr, $field: expr, optional_vec $(, $self: ident)?) => { if !$field.is_empty() { @@ -320,7 +320,7 @@ macro_rules! _check_decoded_tlv_order { ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (option, explicit_type: $fieldty: ty)) => {{ // no-op }}; - ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (legacy, $fieldty: ty, $write: expr)) => {{ + ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (legacy, $fieldty: ty, $read: expr, $write: expr)) => {{ // no-op }}; ($last_seen_type: expr, $typ: expr, $type: expr, $field: ident, (custom, $fieldty: ty, $read: expr, $write: expr) $(, $self: ident)?) => {{ @@ -398,8 +398,10 @@ macro_rules! _check_missing_tlv { ($last_seen_type: expr, $type: expr, $field: ident, (option, explicit_type: $fieldty: ty)) => {{ // no-op }}; - ($last_seen_type: expr, $type: expr, $field: ident, (legacy, $fieldty: ty, $write: expr)) => {{ - // no-op + ($last_seen_type: expr, $type: expr, $field: ident, (legacy, $fieldty: ty, $read: expr, $write: expr)) => {{ + use $crate::ln::msgs::DecodeError; + let read_result: Result<(), DecodeError> = $read($field.as_ref()); + read_result?; }}; ($last_seen_type: expr, $type: expr, $field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => {{ // Note that $type may be 0 making the second comparison always false @@ -463,7 +465,7 @@ macro_rules! _decode_tlv { let _field: &Option<$fieldty> = &$field; $crate::_decode_tlv!($outer_reader, $reader, $field, option); }}; - ($outer_reader: expr, $reader: expr, $field: ident, (legacy, $fieldty: ty, $write: expr)) => {{ + ($outer_reader: expr, $reader: expr, $field: ident, (legacy, $fieldty: ty, $read: expr, $write: expr)) => {{ $crate::_decode_tlv!($outer_reader, $reader, $field, (option, explicit_type: $fieldty)); }}; ($outer_reader: expr, $reader: expr, $field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => {{ @@ -858,7 +860,7 @@ macro_rules! _init_tlv_based_struct_field { ($field: ident, option) => { $field }; - ($field: ident, (legacy, $fieldty: ty, $write: expr)) => { + ($field: ident, (legacy, $fieldty: ty, $read: expr, $write: expr)) => { $crate::_init_tlv_based_struct_field!($field, option) }; ($field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => { @@ -927,7 +929,7 @@ macro_rules! _init_tlv_field_var { ($field: ident, (option, explicit_type: $fieldty: ty)) => { let mut $field: Option<$fieldty> = None; }; - ($field: ident, (legacy, $fieldty: ty, $write: expr)) => { + ($field: ident, (legacy, $fieldty: ty, $read: expr, $write: expr)) => { $crate::_init_tlv_field_var!($field, (option, explicit_type: $fieldty)); }; ($field: ident, (custom, $fieldty: ty, $read: expr, $write: expr)) => { @@ -1012,10 +1014,12 @@ macro_rules! _decode_and_build { /// [`MaybeReadable`], requiring the TLV to be present. /// If `$fieldty` is `optional_vec`, then `$field` is a [`Vec`], which needs to have its individual elements serialized. /// Note that for `optional_vec` no bytes are written if the vec is empty -/// If `$fieldty` is `(legacy, $ty, $write)` then, when writing, the function $write will be +/// If `$fieldty` is `(legacy, $ty, $read, $write)` then, when writing, the function $write will be /// called with the object being serialized and a returned `Option` and is written as a TLV if -/// `Some`. When reading, an optional field of type `$ty` is read (which can be used in later -/// `default_value` or `static_value` fields by referring to the value by name). +/// `Some`. When reading, an optional field of type `$ty` is read, and after all TLV fields are +/// read, the `$read` closure is called with the `Option<$ty>` value. The `$read` closure should +/// return a `Result, DecodeError>`. Legacy field values can be used in later +/// `default_value` or `static_value` fields by referring to the value by name. /// If `$fieldty` is `(custom, $ty, $read, $write)` then, when writing, the same behavior as /// `legacy`, above is used. When reading, if a TLV is present, it is read as `$ty` and the /// `$read` method is called with `Some(decoded_$ty_object)`. If no TLV is present, the field @@ -1039,7 +1043,7 @@ macro_rules! _decode_and_build { /// (1, tlv_default_integer, (default_value, 7)), /// (2, tlv_optional_integer, option), /// (3, tlv_vec_type_integer, optional_vec), -/// (4, unwritten_type, (legacy, u32, |us: &LightningMessage| Some(us.tlv_integer))), +/// (4, unwritten_type, (legacy, u32, |_| Ok(()), |us: &LightningMessage| Some(us.tlv_integer))), /// (_unused, tlv_upgraded_integer, (static_value, unwritten_type.unwrap_or(0) * 2)) /// }); /// ``` @@ -1931,7 +1935,7 @@ mod tests { new_field: (u8, u8), } impl_writeable_tlv_based!(ExpandedField, { - (0, old_field, (legacy, u8, |us: &ExpandedField| Some(us.new_field.0))), + (0, old_field, (legacy, u8, |_| Ok(()), |us: &ExpandedField| Some(us.new_field.0))), (1, new_field, (default_value, (old_field.ok_or(DecodeError::InvalidValue)?, 0))), }); From d215a8873acaac2d4b507105fe3134a85f2f5a6b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 13 Jan 2026 16:45:52 -0600 Subject: [PATCH 03/21] Move FundingTxInput::sequence to Utxo A forthcoming commit will change CoinSelection to include FundingTxInput instead of Utxo, though the former will probably be renamed. This is so CoinSelectionSource can be used when funding a splice. Further updating WalletSource to use FundingTxInput is not desirable, however, as it would result in looking up each confirmed UTXOs previous transaction even if it is not selected. See Wallet's implementation of CoinSelectionSource, which delegates to WalletSource for listing all confirmed UTXOs. This commit moves FundingTxInput::sequence to Utxo, and thus the responsibility for setting it to WalletSource implementations. Doing so will allow Wallet's CoinSelectionSource implementation to delegate looking up previous transactions to WalletSource without having to explicitly set the sequence on any FundingTxInput. --- lightning/src/events/bump_transaction/mod.rs | 10 +++++++++- lightning/src/ln/funding.rs | 13 ++++--------- lightning/src/ln/interactivetxs.rs | 8 ++++++-- lightning/src/util/anchor_channel_reserves.rs | 3 ++- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index ff1bcca4b12..802d540546e 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -284,12 +284,15 @@ pub struct Utxo { /// with their lengths included, required to satisfy the output's script. The weight consumed by /// the input's `script_sig` must account for [`WITNESS_SCALE_FACTOR`]. pub satisfaction_weight: u64, + /// The sequence number to use in the [`TxIn`] when spending the UTXO. + pub sequence: Sequence, } impl_writeable_tlv_based!(Utxo, { (1, outpoint, required), (3, output, required), (5, satisfaction_weight, required), + (7, sequence, (default_value, Sequence::ENABLE_RBF_NO_LOCKTIME)), }); impl Utxo { @@ -304,6 +307,7 @@ impl Utxo { outpoint, output: TxOut { value, script_pubkey: ScriptBuf::new_p2pkh(pubkey_hash) }, satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + 1, /* empty witness */ + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } @@ -323,6 +327,7 @@ impl Utxo { }, satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } @@ -332,6 +337,7 @@ impl Utxo { outpoint, output: TxOut { value, script_pubkey: ScriptBuf::new_p2wpkh(pubkey_hash) }, satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } @@ -343,6 +349,7 @@ impl Utxo { outpoint, output: TxOut { value, script_pubkey: ScriptBuf::new_p2tr_tweaked(tweaked_public_key) }, satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } } @@ -737,7 +744,7 @@ where tx.input.push(TxIn { previous_output: utxo.outpoint, script_sig: ScriptBuf::new(), - sequence: Sequence::ZERO, + sequence: utxo.sequence, witness: Witness::new(), }); } @@ -1371,6 +1378,7 @@ mod tests { script_pubkey: ScriptBuf::new(), }, satisfaction_weight: 5, // Just the script_sig and witness lengths + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, }], change_output: None, }, diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 8092a0e4451..8e17d2cb3b9 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -108,11 +108,6 @@ pub struct FundingTxInput { /// [`TxOut`]: bitcoin::TxOut pub(super) utxo: Utxo, - /// The sequence number to use in the [`TxIn`]. - /// - /// [`TxIn`]: bitcoin::TxIn - pub(super) sequence: Sequence, - /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. /// /// [`TxOut`]: bitcoin::TxOut @@ -122,7 +117,7 @@ pub struct FundingTxInput { impl_writeable_tlv_based!(FundingTxInput, { (1, utxo, required), - (3, sequence, required), + (3, _sequence, (legacy, Sequence, |_| Ok(()), |input: &FundingTxInput| Some(input.utxo.sequence))), (5, prevtx, required), }); @@ -140,8 +135,8 @@ impl FundingTxInput { .ok_or(())? .clone(), satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, }, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, prevtx, }) } @@ -234,14 +229,14 @@ impl FundingTxInput { /// /// [`TxIn`]: bitcoin::TxIn pub fn sequence(&self) -> Sequence { - self.sequence + self.utxo.sequence } /// Sets the sequence number to use in the [`TxIn`]. /// /// [`TxIn`]: bitcoin::TxIn pub fn set_sequence(&mut self, sequence: Sequence) { - self.sequence = sequence; + self.utxo.sequence = sequence; } /// Converts the [`FundingTxInput`] into a [`Utxo`] for coin selection. diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index a004f6e9f14..3c47658e963 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -2054,9 +2054,13 @@ impl InteractiveTxConstructor { let mut inputs_to_contribute: Vec<(SerialId, InputOwned)> = inputs_to_contribute .into_iter() - .map(|FundingTxInput { utxo, sequence, prevtx: prev_tx }| { + .map(|FundingTxInput { utxo, prevtx: prev_tx }| { let serial_id = generate_holder_serial_id(entropy_source, is_initiator); - let txin = TxIn { previous_output: utxo.outpoint, sequence, ..Default::default() }; + let txin = TxIn { + previous_output: utxo.outpoint, + sequence: utxo.sequence, + ..Default::default() + }; let prev_output = utxo.output; let input = InputOwned::Single(SingleOwnedInput { input: txin, diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index 8026af03d58..25a0e7ca0ba 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -315,7 +315,7 @@ where #[cfg(test)] mod test { use super::*; - use bitcoin::{OutPoint, ScriptBuf, TxOut, Txid}; + use bitcoin::{OutPoint, ScriptBuf, Sequence, TxOut, Txid}; use std::str::FromStr; #[test] @@ -343,6 +343,7 @@ mod test { }, output: TxOut { value: amount, script_pubkey: ScriptBuf::new() }, satisfaction_weight: 1 * 4 + (1 + 1 + 72 + 1 + 33), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, } } From 9bf01e21f9bce2e31113a181d63f646f9dc81a1d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 4 Feb 2026 23:15:32 -0600 Subject: [PATCH 04/21] f - update Utxo::sequence --- lightning/src/ln/funding.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 8e17d2cb3b9..50e0938fc8b 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -117,7 +117,19 @@ pub struct FundingTxInput { impl_writeable_tlv_based!(FundingTxInput, { (1, utxo, required), - (3, _sequence, (legacy, Sequence, |_| Ok(()), |input: &FundingTxInput| Some(input.utxo.sequence))), + (3, _sequence, (legacy, Sequence, + |read_val: Option<&Sequence>| { + if let Some(sequence) = read_val { + // Utxo contains sequence now, so update it if the value read here differs since + // this indicates Utxo::sequence was read with default_value + let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required"); + if utxo.sequence != *sequence { + utxo.sequence = *sequence; + } + } + Ok(()) + }, + |input: &FundingTxInput| Some(input.utxo.sequence))), (5, prevtx, required), }); From ce6c45aec1a4a910b80f5d3629fe3c3cbe331bd2 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 13 Jan 2026 21:30:46 -0600 Subject: [PATCH 05/21] Use FundingTxInput instead of Utxo in CoinSelection In order to reuse CoinSelectionSource for splicing, the previous transaction of each UTXO is needed. Update CoinSelection to use FundingTxInput (renamed to ConfirmedUtxo) so that it is available. This requires adding a method to WalletSource to look up a previous transaction for a UTXO. Otherwise, Wallet's implementation of CoinSelectionSource would need WalletSource to include the previous transactions when listing confirmed UTXOs to select from. But this would be inefficient since only some UTXOs are selected. --- fuzz/src/full_stack.rs | 4 +- lightning/src/events/bump_transaction/mod.rs | 110 +++++++++++++----- lightning/src/events/bump_transaction/sync.rs | 13 +++ lightning/src/ln/functional_test_utils.rs | 3 +- lightning/src/ln/funding.rs | 19 ++- lightning/src/util/test_utils.rs | 42 ++++--- 6 files changed, 135 insertions(+), 56 deletions(-) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 600335b5083..2c726fe3f1a 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -668,9 +668,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { script_pubkey: wallet.get_change_script().unwrap(), }], }; - let coinbase_txid = coinbase_tx.compute_txid(); - wallet - .add_utxo(bitcoin::OutPoint { txid: coinbase_txid, vout: 0 }, Amount::from_sat(1_000_000)); + wallet.add_utxo(coinbase_tx.clone(), 0); loop { match get_slice!(1)[0] { diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 802d540546e..bc6707791cb 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -30,6 +30,7 @@ use crate::ln::chan_utils::{ HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT, }; +use crate::ln::funding::FundingTxInput; use crate::ln::types::ChannelId; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -354,13 +355,16 @@ impl Utxo { } } +/// An unspent transaction output with at least one confirmation. +pub type ConfirmedUtxo = FundingTxInput; + /// The result of a successful coin selection attempt for a transaction requiring additional UTXOs /// to cover its fees. #[derive(Clone, Debug)] pub struct CoinSelection { /// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction /// requiring additional fees. - pub confirmed_utxos: Vec, + pub confirmed_utxos: Vec, /// An additional output tracking whether any change remained after coin selection. This output /// should always have a value above dust for its given `script_pubkey`. It should not be /// spent until the transaction it belongs to confirms to ensure mempool descendant limits are @@ -368,6 +372,16 @@ pub struct CoinSelection { pub change_output: Option, } +impl CoinSelection { + fn satisfaction_weight(&self) -> u64 { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() + } + + fn amount(&self) -> Amount { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() + } +} + /// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can /// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, /// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], @@ -438,11 +452,18 @@ pub trait WalletSource { fn list_confirmed_utxos<'a>( &'a self, ) -> impl Future, ()>> + MaybeSend + 'a; + + /// Returns the previous transaction containing the UTXO. + fn get_prevtx<'a>( + &'a self, utxo: &Utxo, + ) -> impl Future> + MaybeSend + 'a; + /// Returns a script to use for change above dust resulting from a successful coin selection /// attempt. fn get_change_script<'a>( &'a self, ) -> impl Future> + MaybeSend + 'a; + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within /// the transaction known to the wallet (i.e., any provided via /// [`WalletSource::list_confirmed_utxos`]). @@ -628,10 +649,26 @@ where Some(TxOut { script_pubkey: change_script, value: change_output_amount }) }; - Ok(CoinSelection { - confirmed_utxos: selected_utxos.into_iter().map(|(utxo, _)| utxo).collect(), - change_output, - }) + let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); + for (utxo, _) in selected_utxos { + let prevtx = self.source.get_prevtx(&utxo).await?; + let prevtx_id = prevtx.compute_txid(); + if prevtx_id != utxo.outpoint.txid + || prevtx.output.get(utxo.outpoint.vout as usize).is_none() + { + log_error!( + self.logger, + "Tx {} from wallet source doesn't contain output referenced by outpoint: {}", + prevtx_id, + utxo.outpoint, + ); + return Err(()); + } + + confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx }); + } + + Ok(CoinSelection { confirmed_utxos, change_output }) } } @@ -740,7 +777,7 @@ where /// Updates a transaction with the result of a successful coin selection attempt. fn process_coin_selection(&self, tx: &mut Transaction, coin_selection: &CoinSelection) { - for utxo in coin_selection.confirmed_utxos.iter() { + for ConfirmedUtxo { utxo, .. } in coin_selection.confirmed_utxos.iter() { tx.input.push(TxIn { previous_output: utxo.outpoint, script_sig: ScriptBuf::new(), @@ -862,12 +899,10 @@ where output: vec![], }; - let input_satisfaction_weight: u64 = - coin_selection.confirmed_utxos.iter().map(|utxo| utxo.satisfaction_weight).sum(); + let input_satisfaction_weight = coin_selection.satisfaction_weight(); let total_satisfaction_weight = anchor_input_witness_weight + EMPTY_SCRIPT_SIG_WEIGHT + input_satisfaction_weight; - let total_input_amount = must_spend_amount - + coin_selection.confirmed_utxos.iter().map(|utxo| utxo.output.value).sum(); + let total_input_amount = must_spend_amount + coin_selection.amount(); self.process_coin_selection(&mut anchor_tx, &coin_selection); let anchor_txid = anchor_tx.compute_txid(); @@ -882,10 +917,10 @@ where let index = idx + 1; debug_assert_eq!( anchor_psbt.unsigned_tx.input[index].previous_output, - utxo.outpoint + utxo.outpoint() ); - if utxo.output.script_pubkey.is_witness_program() { - anchor_psbt.inputs[index].witness_utxo = Some(utxo.output); + if utxo.output().script_pubkey.is_witness_program() { + anchor_psbt.inputs[index].witness_utxo = Some(utxo.into_output()); } } @@ -1117,13 +1152,11 @@ where utxo_id = claim_id.step_with_bytes(&broadcasted_htlcs.to_be_bytes()); #[cfg(debug_assertions)] - let input_satisfaction_weight: u64 = - coin_selection.confirmed_utxos.iter().map(|utxo| utxo.satisfaction_weight).sum(); + let input_satisfaction_weight = coin_selection.satisfaction_weight(); #[cfg(debug_assertions)] let total_satisfaction_weight = must_spend_satisfaction_weight + input_satisfaction_weight; #[cfg(debug_assertions)] - let input_value: u64 = - coin_selection.confirmed_utxos.iter().map(|utxo| utxo.output.value.to_sat()).sum(); + let input_value = coin_selection.amount().to_sat(); #[cfg(debug_assertions)] let total_input_amount = must_spend_amount + input_value; @@ -1144,9 +1177,12 @@ where for (idx, utxo) in coin_selection.confirmed_utxos.into_iter().enumerate() { // offset to skip the htlc inputs let index = idx + selected_htlcs.len(); - debug_assert_eq!(htlc_psbt.unsigned_tx.input[index].previous_output, utxo.outpoint); - if utxo.output.script_pubkey.is_witness_program() { - htlc_psbt.inputs[index].witness_utxo = Some(utxo.output); + debug_assert_eq!( + htlc_psbt.unsigned_tx.input[index].previous_output, + utxo.outpoint() + ); + if utxo.output().script_pubkey.is_witness_program() { + htlc_psbt.inputs[index].witness_utxo = Some(utxo.into_output()); } } @@ -1290,10 +1326,9 @@ mod tests { use crate::util::ser::Readable; use crate::util::test_utils::{TestBroadcaster, TestLogger}; - use bitcoin::hashes::Hash; use bitcoin::hex::FromHex; use bitcoin::{ - Network, ScriptBuf, Transaction, Txid, WitnessProgram, WitnessVersion, XOnlyPublicKey, + Network, ScriptBuf, Transaction, WitnessProgram, WitnessVersion, XOnlyPublicKey, }; struct TestCoinSelectionSource { @@ -1314,9 +1349,17 @@ mod tests { Ok(res) } fn sign_psbt(&self, psbt: Psbt) -> Result { + let prevtx_ids: Vec<_> = self + .expected_selects + .lock() + .unwrap() + .iter() + .flat_map(|selection| selection.3.confirmed_utxos.iter()) + .map(|utxo| utxo.prevtx.compute_txid()) + .collect(); let mut tx = psbt.unsigned_tx; for input in tx.input.iter_mut() { - if input.previous_output.txid != Txid::from_byte_array([44; 32]) { + if prevtx_ids.contains(&input.previous_output.txid) { // Channel output, add a realistic size witness to make the assertions happy input.witness = Witness::from_slice(&[vec![42; 162]]); } @@ -1357,6 +1400,13 @@ mod tests { .weight() .to_wu(); + let prevtx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![], + output: vec![TxOut { value: Amount::from_sat(200), script_pubkey: ScriptBuf::new() }], + }; + let broadcaster = TestBroadcaster::new(Network::Testnet); let source = TestCoinSelectionSource { expected_selects: Mutex::new(vec![ @@ -1371,14 +1421,14 @@ mod tests { commitment_and_anchor_fee, 868, CoinSelection { - confirmed_utxos: vec![Utxo { - outpoint: OutPoint { txid: Txid::from_byte_array([44; 32]), vout: 0 }, - output: TxOut { - value: Amount::from_sat(200), - script_pubkey: ScriptBuf::new(), + confirmed_utxos: vec![ConfirmedUtxo { + utxo: Utxo { + outpoint: OutPoint { txid: prevtx.compute_txid(), vout: 0 }, + output: prevtx.output[0].clone(), + satisfaction_weight: 5, // Just the script_sig and witness lengths + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, }, - satisfaction_weight: 5, // Just the script_sig and witness lengths - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + prevtx, }], change_output: None, }, diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index f4245cd5194..a8b4c05f1ea 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -37,9 +37,14 @@ use super::{ pub trait WalletSourceSync { /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. fn list_confirmed_utxos(&self) -> Result, ()>; + + /// Returns the previous transaction containing the UTXO. + fn get_prevtx(&self, utxo: &Utxo) -> Result; + /// Returns a script to use for change above dust resulting from a successful coin selection /// attempt. fn get_change_script(&self) -> Result; + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within /// the transaction known to the wallet (i.e., any provided via /// [`WalletSource::list_confirmed_utxos`]). @@ -79,6 +84,14 @@ where async move { utxos } } + /// Returns the previous transaction containing the UTXO. + fn get_prevtx<'a>( + &'a self, utxo: &Utxo, + ) -> impl Future> + MaybeSend + 'a { + let prevtx = self.0.get_prevtx(utxo); + Box::pin(async move { prevtx }) + } + fn get_change_script<'a>( &'a self, ) -> impl Future> + MaybeSend + 'a { diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index e8965752331..140e8f8fa58 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -397,8 +397,7 @@ fn do_connect_block_without_consistency_checks<'a, 'b, 'c, 'd>( let wallet_script = node.wallet_source.get_change_script().unwrap(); for (idx, output) in tx.output.iter().enumerate() { if output.script_pubkey == wallet_script { - let outpoint = bitcoin::OutPoint { txid: tx.compute_txid(), vout: idx as u32 }; - node.wallet_source.add_utxo(outpoint, output.value); + node.wallet_source.add_utxo(tx.clone(), idx as u32); } } } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 50e0938fc8b..9981250b05e 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -103,16 +103,17 @@ impl SpliceContribution { /// establishment protocol or when splicing. #[derive(Debug, Clone)] pub struct FundingTxInput { - /// The unspent [`TxOut`] that the input spends. + /// The unspent [`TxOut`] found in [`prevtx`]. /// /// [`TxOut`]: bitcoin::TxOut - pub(super) utxo: Utxo, + /// [`prevtx`]: Self::prevtx + pub(crate) utxo: Utxo, /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. /// /// [`TxOut`]: bitcoin::TxOut /// [`utxo`]: Self::utxo - pub(super) prevtx: Transaction, + pub(crate) prevtx: Transaction, } impl_writeable_tlv_based!(FundingTxInput, { @@ -237,6 +238,11 @@ impl FundingTxInput { self.utxo.outpoint } + /// The unspent output. + pub fn output(&self) -> &TxOut { + &self.utxo.output + } + /// The sequence number to use in the [`TxIn`]. /// /// [`TxIn`]: bitcoin::TxIn @@ -251,8 +257,13 @@ impl FundingTxInput { self.utxo.sequence = sequence; } - /// Converts the [`FundingTxInput`] into a [`Utxo`] for coin selection. + /// Converts the [`FundingTxInput`] into a [`Utxo`]. pub fn into_utxo(self) -> Utxo { self.utxo } + + /// Converts the [`FundingTxInput`] into a [`TxOut`]. + pub fn into_output(self) -> TxOut { + self.utxo.output + } } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 34f5d5fe36e..f1681f432e6 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -22,7 +22,7 @@ use crate::chain::channelmonitor::{ use crate::chain::transaction::OutPoint; use crate::chain::WatchedOutput; use crate::events::bump_transaction::sync::WalletSourceSync; -use crate::events::bump_transaction::Utxo; +use crate::events::bump_transaction::{ConfirmedUtxo, Utxo}; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; use crate::ln::channel_state::ChannelDetails; @@ -2211,7 +2211,7 @@ impl Drop for TestScorer { pub struct TestWalletSource { secret_key: SecretKey, - utxos: Mutex>, + utxos: Mutex>, secp: Secp256k1, } @@ -2220,21 +2220,13 @@ impl TestWalletSource { Self { secret_key, utxos: Mutex::new(Vec::new()), secp: Secp256k1::new() } } - pub fn add_utxo(&self, outpoint: bitcoin::OutPoint, value: Amount) -> TxOut { - let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp)); - let utxo = Utxo::new_v0_p2wpkh(outpoint, value, &public_key.wpubkey_hash().unwrap()); - self.utxos.lock().unwrap().push(utxo.clone()); - utxo.output - } - - pub fn add_custom_utxo(&self, utxo: Utxo) -> TxOut { - let output = utxo.output.clone(); + pub fn add_utxo(&self, prevtx: Transaction, vout: u32) { + let utxo = ConfirmedUtxo::new_p2wpkh(prevtx, vout).unwrap(); self.utxos.lock().unwrap().push(utxo); - output } pub fn remove_utxo(&self, outpoint: bitcoin::OutPoint) { - self.utxos.lock().unwrap().retain(|utxo| utxo.outpoint != outpoint); + self.utxos.lock().unwrap().retain(|utxo| utxo.outpoint() != outpoint); } pub fn clear_utxos(&self) { @@ -2247,12 +2239,12 @@ impl TestWalletSource { let utxos = self.utxos.lock().unwrap(); for i in 0..tx.input.len() { if let Some(utxo) = - utxos.iter().find(|utxo| utxo.outpoint == tx.input[i].previous_output) + utxos.iter().find(|utxo| utxo.outpoint() == tx.input[i].previous_output) { let sighash = SighashCache::new(&tx).p2wpkh_signature_hash( i, - &utxo.output.script_pubkey, - utxo.output.value, + &utxo.output().script_pubkey, + utxo.output().value, EcdsaSighashType::All, )?; #[cfg(not(feature = "grind_signatures"))] @@ -2277,7 +2269,23 @@ impl TestWalletSource { impl WalletSourceSync for TestWalletSource { fn list_confirmed_utxos(&self) -> Result, ()> { - Ok(self.utxos.lock().unwrap().clone()) + Ok(self + .utxos + .lock() + .unwrap() + .iter() + .map(|ConfirmedUtxo { utxo, .. }| utxo.clone()) + .collect()) + } + + fn get_prevtx(&self, utxo: &Utxo) -> Result { + self.utxos + .lock() + .unwrap() + .iter() + .find(|confirmed_utxo| confirmed_utxo.utxo == *utxo) + .map(|ConfirmedUtxo { prevtx, .. }| prevtx.clone()) + .ok_or(()) } fn get_change_script(&self) -> Result { From 51c257f6266bc419f2db3a842ba4407c260eddf1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 4 Feb 2026 16:15:51 -0600 Subject: [PATCH 06/21] f - use OutPoint instead of Utxo in get_prevtx --- lightning/src/events/bump_transaction/mod.rs | 6 +++--- lightning/src/events/bump_transaction/sync.rs | 11 +++++------ lightning/src/util/test_utils.rs | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index bc6707791cb..127c8819757 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -453,9 +453,9 @@ pub trait WalletSource { &'a self, ) -> impl Future, ()>> + MaybeSend + 'a; - /// Returns the previous transaction containing the UTXO. + /// Returns the previous transaction containing the UTXO referenced by the outpoint. fn get_prevtx<'a>( - &'a self, utxo: &Utxo, + &'a self, outpoint: OutPoint, ) -> impl Future> + MaybeSend + 'a; /// Returns a script to use for change above dust resulting from a successful coin selection @@ -651,7 +651,7 @@ where let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); for (utxo, _) in selected_utxos { - let prevtx = self.source.get_prevtx(&utxo).await?; + let prevtx = self.source.get_prevtx(utxo.outpoint).await?; let prevtx_id = prevtx.compute_txid(); if prevtx_id != utxo.outpoint.txid || prevtx.output.get(utxo.outpoint.vout as usize).is_none() diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index a8b4c05f1ea..a521fa9c48a 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -21,7 +21,7 @@ use crate::sign::SignerProvider; use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; use crate::util::logger::Logger; -use bitcoin::{Psbt, ScriptBuf, Transaction, TxOut}; +use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut}; use super::BumpTransactionEvent; use super::{ @@ -38,8 +38,8 @@ pub trait WalletSourceSync { /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. fn list_confirmed_utxos(&self) -> Result, ()>; - /// Returns the previous transaction containing the UTXO. - fn get_prevtx(&self, utxo: &Utxo) -> Result; + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx(&self, outpoint: OutPoint) -> Result; /// Returns a script to use for change above dust resulting from a successful coin selection /// attempt. @@ -84,11 +84,10 @@ where async move { utxos } } - /// Returns the previous transaction containing the UTXO. fn get_prevtx<'a>( - &'a self, utxo: &Utxo, + &'a self, outpoint: OutPoint, ) -> impl Future> + MaybeSend + 'a { - let prevtx = self.0.get_prevtx(utxo); + let prevtx = self.0.get_prevtx(outpoint); Box::pin(async move { prevtx }) } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index f1681f432e6..a8caa32dd1f 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -2278,12 +2278,12 @@ impl WalletSourceSync for TestWalletSource { .collect()) } - fn get_prevtx(&self, utxo: &Utxo) -> Result { + fn get_prevtx(&self, outpoint: bitcoin::OutPoint) -> Result { self.utxos .lock() .unwrap() .iter() - .find(|confirmed_utxo| confirmed_utxo.utxo == *utxo) + .find(|confirmed_utxo| confirmed_utxo.utxo.outpoint == outpoint) .map(|ConfirmedUtxo { prevtx, .. }| prevtx.clone()) .ok_or(()) } From 2c0300856bb143e98a42aa6f4386a491db04db9a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 4 Feb 2026 23:42:17 -0600 Subject: [PATCH 07/21] f - s/amount/input_amount --- lightning/src/events/bump_transaction/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 127c8819757..a10c0342667 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -377,7 +377,7 @@ impl CoinSelection { self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() } - fn amount(&self) -> Amount { + fn input_amount(&self) -> Amount { self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() } } @@ -902,7 +902,7 @@ where let input_satisfaction_weight = coin_selection.satisfaction_weight(); let total_satisfaction_weight = anchor_input_witness_weight + EMPTY_SCRIPT_SIG_WEIGHT + input_satisfaction_weight; - let total_input_amount = must_spend_amount + coin_selection.amount(); + let total_input_amount = must_spend_amount + coin_selection.input_amount(); self.process_coin_selection(&mut anchor_tx, &coin_selection); let anchor_txid = anchor_tx.compute_txid(); @@ -1156,7 +1156,7 @@ where #[cfg(debug_assertions)] let total_satisfaction_weight = must_spend_satisfaction_weight + input_satisfaction_weight; #[cfg(debug_assertions)] - let input_value = coin_selection.amount().to_sat(); + let input_value = coin_selection.input_amount().to_sat(); #[cfg(debug_assertions)] let total_input_amount = must_spend_amount + input_value; From d1817f72743089911ede3902ad3d50d025d576d3 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 15:32:51 -0600 Subject: [PATCH 08/21] Make ClaimId optional in coin selection CoinSelectionSource is used for anchor bumping where a ClaimId is passed in to avoid double spending other claims. To re-use this trait for funding a splice, the ClaimId must be optional. And, if None, then any locked UTXOs may be considered ineligible by an implementation. --- lightning/src/events/bump_transaction/mod.rs | 21 ++++++++++++------- lightning/src/events/bump_transaction/sync.rs | 6 +++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index a10c0342667..bfd25388020 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -425,9 +425,12 @@ pub trait CoinSelectionSource { /// which UTXOs to double spend is left to the implementation, but it must strive to keep the /// set of other claims being double spent to a minimum. /// + /// If `claim_id` is not set, then the selection should be treated as if it were for a unique + /// claim, (i.e., it should avoid double spending as described above). + /// /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims fn select_confirmed_utxos<'a>( - &'a self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &'a [TxOut], + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> impl Future> + MaybeSend + 'a; /// Signs and provides the full witness for all inputs within the transaction known to the @@ -492,7 +495,7 @@ where // TODO: Do we care about cleaning this up once the UTXOs have a confirmed spend? We can do so // by checking whether any UTXOs that exist in the map are no longer returned in // `list_confirmed_utxos`. - locked_utxos: Mutex>, + locked_utxos: Mutex>>, } impl Wallet @@ -514,7 +517,7 @@ where /// least 1 satoshi at the current feerate, otherwise, we'll only attempt to spend those which /// contribute at least twice their fee. async fn select_confirmed_utxos_internal( - &self, utxos: &[Utxo], claim_id: ClaimId, force_conflicting_utxo_spend: bool, + &self, utxos: &[Utxo], claim_id: Option, force_conflicting_utxo_spend: bool, tolerate_high_network_feerates: bool, target_feerate_sat_per_1000_weight: u32, preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, max_tx_weight: u64, @@ -538,7 +541,9 @@ where .iter() .filter_map(|utxo| { if let Some(utxo_claim_id) = locked_utxos.get(&utxo.outpoint) { - if *utxo_claim_id != claim_id && !force_conflicting_utxo_spend { + if (utxo_claim_id.is_none() || *utxo_claim_id != claim_id) + && !force_conflicting_utxo_spend + { log_trace!( self.logger, "Skipping UTXO {} to prevent conflicting spend", @@ -678,7 +683,7 @@ where W::Target: WalletSource + MaybeSend + MaybeSync, { fn select_confirmed_utxos<'a>( - &'a self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &'a [TxOut], + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> impl Future> + MaybeSend + 'a { async move { @@ -871,7 +876,7 @@ where let coin_selection: CoinSelection = self .utxo_source .select_confirmed_utxos( - claim_id, + Some(claim_id), must_spend, &[], package_target_feerate_sat_per_1000_weight, @@ -1127,7 +1132,7 @@ where let coin_selection: CoinSelection = match self .utxo_source .select_confirmed_utxos( - utxo_id, + Some(utxo_id), must_spend, &htlc_tx.output, target_feerate_sat_per_1000_weight, @@ -1337,7 +1342,7 @@ mod tests { } impl CoinSelectionSourceSync for TestCoinSelectionSource { fn select_confirmed_utxos( - &self, _claim_id: ClaimId, must_spend: Vec, _must_pay_to: &[TxOut], + &self, _claim_id: Option, must_spend: Vec, _must_pay_to: &[TxOut], target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64, ) -> Result { let mut expected_selects = self.expected_selects.lock().unwrap(); diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index a521fa9c48a..39088bb0e97 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -135,7 +135,7 @@ where W::Target: WalletSourceSync + MaybeSend + MaybeSync, { fn select_confirmed_utxos( - &self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &[TxOut], + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> Result { let fut = self.wallet.select_confirmed_utxos( @@ -214,7 +214,7 @@ pub trait CoinSelectionSourceSync { /// /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims fn select_confirmed_utxos( - &self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &[TxOut], + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> Result; @@ -247,7 +247,7 @@ where T::Target: CoinSelectionSourceSync, { fn select_confirmed_utxos<'a>( - &'a self, claim_id: ClaimId, must_spend: Vec, must_pay_to: &'a [TxOut], + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, ) -> impl Future> + MaybeSend + 'a { let coins = self.0.select_confirmed_utxos( From 414e34adfd9f740fea44daa6a4a3f77c5c336687 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 4 Feb 2026 17:06:51 -0600 Subject: [PATCH 09/21] f - avoid force_conflicting_utxo_spend without claim_id --- lightning/src/events/bump_transaction/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index bfd25388020..db855a892b0 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -522,6 +522,8 @@ where preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, max_tx_weight: u64, ) -> Result { + debug_assert!(!(claim_id.is_none() && force_conflicting_utxo_spend)); + // P2WSH and P2TR outputs are both the heaviest-weight standard outputs at 34 bytes let max_coin_selection_weight = max_tx_weight .checked_sub(preexisting_tx_weight + P2WSH_TXOUT_WEIGHT) @@ -708,6 +710,9 @@ where let configs = [(false, false), (false, true), (true, false), (true, true)]; for (force_conflicting_utxo_spend, tolerate_high_network_feerates) in configs { + if claim_id.is_none() && force_conflicting_utxo_spend { + continue; + } log_debug!( self.logger, "Attempting coin selection targeting {} sat/kW (force_conflicting_utxo_spend = {}, tolerate_high_network_feerates = {})", From 1fc7577384174c6bd22e7cd9d8a9319ecf8dc173 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 18 Dec 2025 09:54:00 -0600 Subject: [PATCH 10/21] Add FundingNeeded event for splicing Rather than requiring the user to pass FundingTxInputs when initiating a splice, generate a FundingNeeded event once the channel has become quiescent. This simplifies error handling and UTXO / change address clean-up by consolidating it in SpliceFailed event handling. Later, this event will be used for opportunistic contributions (i.e., when the counterparty wins quiescence or initiates), dual-funding, and RBF. --- fuzz/src/chanmon_consistency.rs | 203 +++--- fuzz/src/full_stack.rs | 83 ++- lightning/src/events/bump_transaction/mod.rs | 6 + lightning/src/events/mod.rs | 39 ++ lightning/src/ln/channel.rs | 651 ++++++------------ lightning/src/ln/channelmanager.rs | 184 ++++- lightning/src/ln/functional_test_utils.rs | 8 +- lightning/src/ln/funding.rs | 650 ++++++++++++++++- lightning/src/ln/splicing_tests.rs | 424 +++++++----- lightning/src/ln/zero_fee_commitment_tests.rs | 4 +- lightning/src/util/ser.rs | 14 + 11 files changed, 1422 insertions(+), 844 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 5fb07431b17..9debfc3a6a5 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -26,6 +26,7 @@ use bitcoin::opcodes; use bitcoin::script::{Builder, ScriptBuf}; use bitcoin::transaction::Version; use bitcoin::transaction::{Transaction, TxOut}; +use bitcoin::FeeRate; use bitcoin::hash_types::BlockHash; use bitcoin::hashes::sha256::Hash as Sha256; @@ -43,6 +44,7 @@ use lightning::chain::{ chainmonitor, channelmonitor, BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch, }; use lightning::events; +use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS, }; @@ -51,7 +53,7 @@ use lightning::ln::channelmanager::{ ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RecentPaymentDetails, }; use lightning::ln::functional_test_utils::*; -use lightning::ln::funding::{FundingTxInput, SpliceContribution}; +use lightning::ln::funding::SpliceContribution; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, CommitmentUpdate, Init, MessageSendEvent, @@ -70,12 +72,14 @@ use lightning::sign::{ SignerProvider, }; use lightning::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; +use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::config::UserConfig; use lightning::util::errors::APIError; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; use lightning::util::ser::{LengthReadable, ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; +use lightning::util::test_utils::TestWalletSource; use lightning_invoice::RawBolt11Invoice; @@ -174,63 +178,6 @@ impl Writer for VecWriter { } } -pub struct TestWallet { - secret_key: SecretKey, - utxos: Mutex>, - secp: Secp256k1, -} - -impl TestWallet { - pub fn new(secret_key: SecretKey) -> Self { - Self { secret_key, utxos: Mutex::new(Vec::new()), secp: Secp256k1::new() } - } - - fn get_change_script(&self) -> Result { - let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp)); - Ok(ScriptBuf::new_p2wpkh(&public_key.wpubkey_hash().unwrap())) - } - - pub fn add_utxo(&self, outpoint: bitcoin::OutPoint, value: Amount) -> TxOut { - let public_key = bitcoin::PublicKey::new(self.secret_key.public_key(&self.secp)); - let utxo = lightning::events::bump_transaction::Utxo::new_v0_p2wpkh( - outpoint, - value, - &public_key.wpubkey_hash().unwrap(), - ); - self.utxos.lock().unwrap().push(utxo.clone()); - utxo.output - } - - pub fn sign_tx( - &self, mut tx: Transaction, - ) -> Result { - let utxos = self.utxos.lock().unwrap(); - for i in 0..tx.input.len() { - if let Some(utxo) = - utxos.iter().find(|utxo| utxo.outpoint == tx.input[i].previous_output) - { - let sighash = bitcoin::sighash::SighashCache::new(&tx).p2wpkh_signature_hash( - i, - &utxo.output.script_pubkey, - utxo.output.value, - bitcoin::EcdsaSighashType::All, - )?; - let signature = self.secp.sign_ecdsa( - &secp256k1::Message::from_digest(sighash.to_byte_array()), - &self.secret_key, - ); - let bitcoin_sig = bitcoin::ecdsa::Signature { - signature, - sighash_type: bitcoin::EcdsaSighashType::All, - }; - tx.input[i].witness = - bitcoin::Witness::p2wpkh(&bitcoin_sig, &self.secret_key.public_key(&self.secp)); - } - } - Ok(tx) - } -} - /// The LDK API requires that any time we tell it we're done persisting a `ChannelMonitor[Update]` /// we never pass it in as the "latest" `ChannelMonitor` on startup. However, we can pass /// out-of-date monitors as long as we never told LDK we finished persisting them, which we do by @@ -540,7 +487,7 @@ type ChanMan<'a> = ChannelManager< Arc, &'a FuzzRouter, &'a FuzzRouter, - Arc, + Arc, >; #[inline] @@ -657,7 +604,9 @@ fn send_hop_payment( } #[inline] -pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { +pub fn do_test( + data: &[u8], underlying_out: Out, anchors: bool, +) { let out = SearchingOutput::new(underlying_out); let broadcast = Arc::new(TestBroadcaster { txn_broadcasted: RefCell::new(Vec::new()) }); let router = FuzzRouter {}; @@ -684,7 +633,7 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { macro_rules! make_node { ($node_id: expr, $fee_estimator: expr) => {{ - let logger: Arc = + let logger: Arc = Arc::new(test_logger::TestLogger::new($node_id.to_string(), out.clone())); let node_secret = SecretKey::from_slice(&[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, @@ -734,6 +683,7 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { ), monitor, keys_manager, + logger, ) }}; } @@ -745,7 +695,7 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { keys, fee_estimator| { let keys_manager = Arc::clone(keys); - let logger: Arc = + let logger: Arc = Arc::new(test_logger::TestLogger::new(node_id.to_string(), out.clone())); let chain_monitor = Arc::new(TestChainMonitor::new( broadcast.clone(), @@ -1039,9 +989,9 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }}; } - let wallet_a = TestWallet::new(SecretKey::from_slice(&[1; 32]).unwrap()); - let wallet_b = TestWallet::new(SecretKey::from_slice(&[2; 32]).unwrap()); - let wallet_c = TestWallet::new(SecretKey::from_slice(&[3; 32]).unwrap()); + let wallet_a = TestWalletSource::new(SecretKey::from_slice(&[1; 32]).unwrap()); + let wallet_b = TestWalletSource::new(SecretKey::from_slice(&[2; 32]).unwrap()); + let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap()); let wallets = vec![wallet_a, wallet_b, wallet_c]; let coinbase_tx = bitcoin::Transaction { version: bitcoin::transaction::Version::TWO, @@ -1055,12 +1005,8 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }) .collect(), }; - let coinbase_txid = coinbase_tx.compute_txid(); wallets.iter().enumerate().for_each(|(i, w)| { - w.add_utxo( - bitcoin::OutPoint { txid: coinbase_txid, vout: i as u32 }, - Amount::from_sat(100_000), - ); + w.add_utxo(coinbase_tx.clone(), i as u32); }); let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) }); @@ -1072,11 +1018,13 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { // 3 nodes is enough to hit all the possible cases, notably unknown-source-unknown-dest // forwarding. - let (node_a, mut monitor_a, keys_manager_a) = make_node!(0, fee_est_a); - let (node_b, mut monitor_b, keys_manager_b) = make_node!(1, fee_est_b); - let (node_c, mut monitor_c, keys_manager_c) = make_node!(2, fee_est_c); + let (node_a, mut monitor_a, keys_manager_a, logger_a) = make_node!(0, fee_est_a); + let (node_b, mut monitor_b, keys_manager_b, logger_b) = make_node!(1, fee_est_b); + let (node_c, mut monitor_c, keys_manager_c, logger_c) = make_node!(2, fee_est_c); let mut nodes = [node_a, node_b, node_c]; + let loggers = [logger_a, logger_b, logger_c]; + let fee_estimators = [Arc::clone(&fee_est_a), Arc::clone(&fee_est_b), Arc::clone(&fee_est_c)]; let chan_1_id = make_channel!(nodes[0], nodes[1], monitor_a, monitor_b, keys_manager_b, 0); let chan_2_id = make_channel!(nodes[1], nodes[2], monitor_b, monitor_c, keys_manager_c, 1); @@ -1561,6 +1509,25 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { events::Event::ChannelReady { .. } => {}, events::Event::HTLCHandlingFailed { .. } => {}, + events::Event::FundingNeeded { + channel_id, + counterparty_node_id, + funding_template, + .. + } => { + let wallet = + WalletSync::new(&wallets[$node], Arc::clone(&loggers[$node])); + let contribution = funding_template.build_sync(&wallet).unwrap(); + let locktime = None; + nodes[$node] + .funding_contributed( + &channel_id, + &counterparty_node_id, + contribution, + locktime, + ) + .unwrap(); + }, events::Event::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -1898,76 +1865,68 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { }, 0xa0 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_a.ret_val.load(atomic::Ordering::Acquire); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let feerate_sat_per_kw = fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[0].splice_channel( &chan_a_id, &nodes[1].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); } }, 0xa1 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 1).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[1].splice_channel( &chan_a_id, &nodes[0].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); } }, 0xa2 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_b.ret_val.load(atomic::Ordering::Acquire); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[1].splice_channel( &chan_b_id, &nodes[2].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); } }, 0xa3 => { - let input = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 1).unwrap(); - let contribution = - SpliceContribution::splice_in(Amount::from_sat(10_000), vec![input], None); - let funding_feerate_sat_per_kw = fee_est_c.ret_val.load(atomic::Ordering::Acquire); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let feerate_sat_per_kw = fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[2].splice_channel( &chan_b_id, &nodes[1].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); @@ -1989,17 +1948,17 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), script_pubkey: coinbase_tx.output[0].script_pubkey.clone(), }]); - let funding_feerate_sat_per_kw = - fee_est_a.ret_val.load(atomic::Ordering::Acquire); + let feerate_sat_per_kw = + fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[0].splice_channel( &chan_a_id, &nodes[1].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); @@ -2018,17 +1977,17 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), }]); - let funding_feerate_sat_per_kw = - fee_est_b.ret_val.load(atomic::Ordering::Acquire); + let feerate_sat_per_kw = + fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[1].splice_channel( &chan_a_id, &nodes[0].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); @@ -2047,17 +2006,17 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), script_pubkey: coinbase_tx.output[1].script_pubkey.clone(), }]); - let funding_feerate_sat_per_kw = - fee_est_b.ret_val.load(atomic::Ordering::Acquire); + let feerate_sat_per_kw = + fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[1].splice_channel( &chan_b_id, &nodes[2].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); @@ -2076,17 +2035,17 @@ pub fn do_test(data: &[u8], underlying_out: Out, anchors: bool) { value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS), script_pubkey: coinbase_tx.output[2].script_pubkey.clone(), }]); - let funding_feerate_sat_per_kw = - fee_est_c.ret_val.load(atomic::Ordering::Acquire); + let feerate_sat_per_kw = + fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); + let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[2].splice_channel( &chan_b_id, &nodes[1].get_our_node_id(), contribution, - funding_feerate_sat_per_kw, - None, + feerate, ) { assert!( - matches!(e, APIError::APIMisuseError { ref err } if err.contains("splice pending")), + matches!(e, APIError::APIMisuseError { ref err } if err.contains("awaiting splice funding")), "{:?}", e ); @@ -2311,7 +2270,7 @@ impl SearchingOutput { } } -pub fn chanmon_consistency_test(data: &[u8], out: Out) { +pub fn chanmon_consistency_test(data: &[u8], out: Out) { do_test(data, out.clone(), false); do_test(data, out, true); } diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 2c726fe3f1a..f114aca26cf 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -22,6 +22,7 @@ use bitcoin::opcodes; use bitcoin::script::{Builder, ScriptBuf}; use bitcoin::transaction::Version; use bitcoin::transaction::{Transaction, TxIn, TxOut}; +use bitcoin::FeeRate; use bitcoin::hash_types::{BlockHash, Txid}; use bitcoin::hashes::sha256::Hash as Sha256; @@ -30,7 +31,7 @@ use bitcoin::hashes::Hash as _; use bitcoin::hex::FromHex; use bitcoin::WPubkeyHash; -use lightning::ln::funding::{FundingTxInput, SpliceContribution}; +use lightning::ln::funding::SpliceContribution; use lightning::blinded_path::message::{BlindedMessagePath, MessageContext, MessageForwardNode}; use lightning::blinded_path::payment::{BlindedPaymentPath, ReceiveTlvs}; @@ -39,7 +40,7 @@ use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, use lightning::chain::chainmonitor; use lightning::chain::transaction::OutPoint; use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen}; -use lightning::events::bump_transaction::sync::WalletSourceSync; +use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::events::Event; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{ @@ -65,6 +66,7 @@ use lightning::sign::{ SignerProvider, }; use lightning::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; +use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::config::{ChannelConfig, UserConfig}; use lightning::util::hash_tables::*; use lightning::util::logger::Logger; @@ -227,7 +229,7 @@ type ChannelMan<'a> = ChannelManager< Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, >, @@ -239,14 +241,20 @@ type ChannelMan<'a> = ChannelManager< Arc, &'a FuzzRouter, &'a FuzzRouter, - Arc, + Arc, >; type PeerMan<'a> = PeerManager< Peer<'a>, Arc>, - Arc>>, Arc, Arc>>, + Arc< + P2PGossipSync< + Arc>>, + Arc, + Arc, + >, + >, IgnoringMessageHandler, - Arc, + Arc, IgnoringMessageHandler, Arc, IgnoringMessageHandler, @@ -260,7 +268,7 @@ struct MoneyLossDetector<'a> { Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, >, @@ -285,7 +293,7 @@ impl<'a> MoneyLossDetector<'a> { Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, >, @@ -520,7 +528,7 @@ impl SignerProvider for KeyProvider { } #[inline] -pub fn do_test(mut data: &[u8], logger: &Arc) { +pub fn do_test(mut data: &[u8], logger: &Arc) { if data.len() < 32 { return; } @@ -1024,21 +1032,16 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { if splice_in_sats == 0 { continue; } - // Create a funding input from the coinbase transaction - if let Ok(input) = FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0) { - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_sats.min(900_000)), // Cap at available funds minus fees - vec![input], - Some(wallet.get_change_script().unwrap()), - ); - let _ = channelmanager.splice_channel( - &chan.channel_id, - &chan.counterparty.node_id, - contribution, - 253, // funding_feerate_per_kw - None, - ); - } + let contribution = SpliceContribution::splice_in( + Amount::from_sat(splice_in_sats.min(900_000)), // Cap at available funds minus fees + Some(wallet.get_change_script().unwrap()), + ); + let _ = channelmanager.splice_channel( + &chan.channel_id, + &chan.counterparty.node_id, + contribution, + FeeRate::from_sat_per_kwu(253), + ); }, // Splice-out: remove funds from a channel 51 => { @@ -1068,8 +1071,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { &chan.channel_id, &chan.counterparty.node_id, contribution, - 253, // funding_feerate_per_kw - None, + FeeRate::from_sat_per_kwu(253), ); }, _ => return, @@ -1100,6 +1102,21 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { intercepted_htlcs.push(intercept_id); } }, + Event::FundingNeeded { + channel_id, counterparty_node_id, funding_template, .. + } => { + let wallet = WalletSync::new(&wallet, Arc::clone(&logger)); + let contribution = funding_template.build_sync(&wallet).unwrap(); + let locktime = None; + channelmanager + .funding_contributed( + &channel_id, + &counterparty_node_id, + contribution, + locktime, + ) + .unwrap(); + }, Event::FundingTransactionReadyForSigning { channel_id, counterparty_node_id, @@ -1126,14 +1143,15 @@ pub fn do_test(mut data: &[u8], logger: &Arc) { } } -pub fn full_stack_test(data: &[u8], out: Out) { - let logger: Arc = Arc::new(test_logger::TestLogger::new("".to_owned(), out)); +pub fn full_stack_test(data: &[u8], out: Out) { + let logger: Arc = + Arc::new(test_logger::TestLogger::new("".to_owned(), out)); do_test(data, &logger); } #[no_mangle] pub extern "C" fn full_stack_run(data: *const u8, datalen: usize) { - let logger: Arc = + let logger: Arc = Arc::new(test_logger::TestLogger::new("".to_owned(), test_logger::DevNull {})); do_test(unsafe { std::slice::from_raw_parts(data, datalen) }, &logger); } @@ -1919,6 +1937,7 @@ pub fn write_fst_seeds(path: &str) { #[cfg(test)] mod tests { + use lightning::util::async_poll::{MaybeSend, MaybeSync}; use lightning::util::logger::{Logger, Record}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -1950,7 +1969,7 @@ mod tests { let test = super::two_peer_forwarding_seed(); let logger = Arc::new(TrackingLogger { lines: Mutex::new(HashMap::new()) }); - super::do_test(&test, &(Arc::clone(&logger) as Arc)); + super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); // 1 @@ -1985,7 +2004,7 @@ mod tests { let test = super::gossip_exchange_seed(); let logger = Arc::new(TrackingLogger { lines: Mutex::new(HashMap::new()) }); - super::do_test(&test, &(Arc::clone(&logger) as Arc)); + super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Sending message to all peers except Some(PublicKey(0000000000000000000000000000000000000000000000000000000000000002ff00000000000000000000000000000000000000000000000000000000000002)) or the announced channel's counterparties: ChannelAnnouncement { node_signature_1: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, node_signature_2: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, bitcoin_signature_1: 3026020200b202200303030303030303030303030303030303030303030303030303030303030303, bitcoin_signature_2: 3026020200b202200202020202020202020202020202020202020202020202020202020202020202, contents: UnsignedChannelAnnouncement { features: [], chain_hash: 6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000, short_channel_id: 42, node_id_1: NodeId(030303030303030303030303030303030303030303030303030303030303030303), node_id_2: NodeId(020202020202020202020202020202020202020202020202020202020202020202), bitcoin_key_1: NodeId(030303030303030303030303030303030303030303030303030303030303030303), bitcoin_key_2: NodeId(020202020202020202020202020202020202020202020202020202020202020202), excess_data: [] } }".to_string())), Some(&1)); @@ -1998,7 +2017,7 @@ mod tests { let test = super::splice_seed(); let logger = Arc::new(TrackingLogger { lines: Mutex::new(HashMap::new()) }); - super::do_test(&test, &(Arc::clone(&logger) as Arc)); + super::do_test(&test, &(Arc::clone(&logger) as Arc)); let log_entries = logger.lines.lock().unwrap(); diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index db855a892b0..0c43a599443 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -273,6 +273,12 @@ pub struct Input { pub satisfaction_weight: u64, } +impl_writeable_tlv_based!(Input, { + (1, outpoint, required), + (3, previous_utxo, required), + (5, satisfaction_weight, required), +}); + /// An unspent transaction output that is available to spend resulting from a successful /// [`CoinSelection`] attempt. #[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 3d860e9f363..02ad5a49483 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -25,6 +25,7 @@ use crate::blinded_path::payment::{ use crate::chain::transaction; use crate::ln::channel::FUNDING_CONF_DEADLINE_BLOCKS; use crate::ln::channelmanager::{InterceptId, PaymentId}; +use crate::ln::funding::FundingTemplate; use crate::ln::msgs; use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::RecipientOnionFields; @@ -1827,6 +1828,37 @@ pub enum Event { /// [`ChannelManager::respond_to_static_invoice_request`]: crate::ln::channelmanager::ChannelManager::respond_to_static_invoice_request invoice_request: InvoiceRequest, }, + /// Indicates that funding is needed for a channel splice. + /// + /// The client should build a [`FundingContribution`] from the provided [`FundingTemplate`] and + /// pass it to [`ChannelManager::funding_contributed`]. If the method is not called while + /// handling the event, it will have the effect of canceling the splice. + /// + /// [`FundingContribution`]: crate::ln::funding::FundingContribution + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + FundingNeeded { + /// The `channel_id` of the channel which you'll need to pass back into + /// [`ChannelManager::funding_contributed`]. + /// + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + channel_id: ChannelId, + /// The counterparty's `node_id`, which you'll need to pass back into + /// [`ChannelManager::funding_contributed`]. + /// + /// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed + counterparty_node_id: PublicKey, + /// The `user_channel_id` value passed in for outbound channels, or for inbound channels if + /// [`UserConfig::manually_accept_inbound_channels`] config flag is set to true. Otherwise + /// `user_channel_id` will be randomized for inbound channels. + /// + /// [`UserConfig::manually_accept_inbound_channels`]: crate::util::config::UserConfig::manually_accept_inbound_channels + user_channel_id: u128, + /// A template for constructing a [`FundingContribution`], which contains information when + /// the funding was initiated. + /// + /// [`FundingContribution`]: crate::ln::funding::FundingContribution + funding_template: FundingTemplate, + }, /// Indicates that a channel funding transaction constructed interactively is ready to be /// signed. This event will only be triggered if at least one input was contributed. /// @@ -2360,6 +2392,11 @@ impl Writeable for Event { (13, *contributed_outputs, optional_vec), }); }, + &Event::FundingNeeded { .. } => { + 53u8.write(writer)?; + // Never write out FundingNeeded events as it's the user's responsibility to + // determine if dual or splice funding has completed. + }, // Note that, going forward, all new events must only write data inside of // `write_tlv_fields`. Versions 0.0.101+ will ignore odd-numbered events that write // data via `write_tlv_fields`. @@ -2994,6 +3031,8 @@ impl MaybeReadable for Event { }; f() }, + // Note that we do not write a length-prefixed TLV for FundingNeeded events. + 53u8 => Ok(None), // Versions prior to 0.0.100 did not ignore odd types, instead returning InvalidValue. // Version 0.0.100 failed to properly ignore odd types, possibly resulting in corrupt // reads. diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 042b388e1a1..d1a48925983 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11,7 +11,7 @@ use bitcoin::absolute::LockTime; use bitcoin::amount::{Amount, SignedAmount}; use bitcoin::consensus::encode; use bitcoin::constants::ChainHash; -use bitcoin::script::{Builder, Script, ScriptBuf, WScriptHash}; +use bitcoin::script::{Builder, Script, ScriptBuf}; use bitcoin::sighash::EcdsaSighashType; use bitcoin::transaction::{Transaction, TxOut}; use bitcoin::Witness; @@ -36,6 +36,7 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; +use crate::events::bump_transaction::Input; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ @@ -43,7 +44,7 @@ use crate::ln::chan_utils::{ selected_commitment_sat_per_1000_weight, ChannelPublicKeys, ChannelTransactionParameters, ClosingTransaction, CommitmentTransaction, CounterpartyChannelTransactionParameters, CounterpartyCommitmentSecrets, HTLCOutputInCommitment, HolderCommitmentTransaction, - BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, + EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, }; use crate::ln::channel_state::{ ChannelShutdownState, CounterpartyForwardingInfo, InboundHTLCDetails, InboundHTLCStateDetails, @@ -55,12 +56,13 @@ use crate::ln::channelmanager::{ RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT, MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::{FundingTxInput, SpliceContribution}; +use crate::ln::funding::{ + FundingContribution, FundingTemplate, FundingTxInput, SpliceContribution, +}; use crate::ln::interactivetxs::{ calculate_change_output_value, get_output_weight, AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs, InteractiveTxMessageSend, InteractiveTxSigningSession, NegotiationError, SharedOwnedInput, SharedOwnedOutput, - TX_COMMON_FIELDS_WEIGHT, }; use crate::ln::msgs; use crate::ln::msgs::{ClosingSigned, ClosingSignedFeeRange, DecodeError, OnionErrorPacket}; @@ -69,7 +71,6 @@ use crate::ln::onion_utils::{ }; use crate::ln::script::{self, ShutdownScript}; use crate::ln::types::ChannelId; -use crate::ln::LN_MAX_MSG_LEN; use crate::offers::static_invoice::StaticInvoice; use crate::routing::gossip::NodeId; use crate::sign::ecdsa::EcdsaChannelSigner; @@ -2590,6 +2591,20 @@ impl FundingScope { self.channel_transaction_parameters.funding_outpoint } + /// Gets the funding output for this channel, if available. + /// + /// When a channel is spliced, this continues to refer to the original funding output (which + /// was spent by the splice transaction) until the splice transaction reaches sufficient + /// confirmations to be locked (and we exchange `splice_locked` messages with our peer). + pub fn get_funding_output(&self) -> Option { + self.channel_transaction_parameters.make_funding_redeemscript_opt().map(|redeem_script| { + TxOut { + value: Amount::from_sat(self.get_value_satoshis()), + script_pubkey: redeem_script.to_p2wsh(), + } + }) + } + fn get_funding_txid(&self) -> Option { self.channel_transaction_parameters.funding_outpoint.map(|txo| txo.txid) } @@ -2914,7 +2929,12 @@ impl_writeable_tlv_based!(SpliceInstructions, { #[derive(Debug)] pub(crate) enum QuiescentAction { - Splice(SpliceInstructions), + // Deprecated in favor of the Splice variant and no longer produced as of LDK 0.3. + LegacySplice(SpliceInstructions), + Splice { + contribution: FundingContribution, + locktime: LockTime, + }, #[cfg(any(test, fuzzing))] DoNothing, } @@ -2927,11 +2947,19 @@ pub(crate) enum StfuResponse { #[cfg(any(test, fuzzing))] impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, (0, DoNothing) => {}, - {1, Splice} => (), + (2, Splice) => { + (0, contribution, required), + (1, locktime, required), + }, + {1, LegacySplice} => (), ); #[cfg(not(any(test, fuzzing)))] -impl_writeable_tlv_based_enum_upgradable!(QuiescentAction,, - {1, Splice} => (), +impl_writeable_tlv_based_enum_upgradable!(QuiescentAction, + (2, Splice) => { + (0, contribution, required), + (1, locktime, required), + }, + {1, LegacySplice} => (), ); /// Wrapper around a [`Transaction`] useful for caching the result of [`Transaction::compute_txid`]. @@ -6537,130 +6565,6 @@ fn get_v2_channel_reserve_satoshis(channel_value_satoshis: u64, dust_limit_satos cmp::min(channel_value_satoshis, cmp::max(q, dust_limit_satoshis)) } -fn check_splice_contribution_sufficient( - contribution: &SpliceContribution, is_initiator: bool, funding_feerate: FeeRate, -) -> Result { - if contribution.inputs().is_empty() { - let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( - contribution.inputs(), - contribution.outputs(), - is_initiator, - true, // is_splice - funding_feerate.to_sat_per_kwu() as u32, - )); - - let contribution_amount = contribution.net_value(); - contribution_amount - .checked_sub( - estimated_fee.to_signed().expect("fees should never exceed Amount::MAX_MONEY"), - ) - .ok_or(format!( - "{estimated_fee} splice-out amount plus {} fee estimate exceeds the total bitcoin supply", - contribution_amount.unsigned_abs(), - )) - } else { - check_v2_funding_inputs_sufficient( - contribution.value_added(), - contribution.inputs(), - contribution.outputs(), - is_initiator, - true, - funding_feerate.to_sat_per_kwu() as u32, - ) - .map(|_| contribution.net_value()) - } -} - -/// Estimate our part of the fee of the new funding transaction. -#[allow(dead_code)] // TODO(dual_funding): TODO(splicing): Remove allow once used. -#[rustfmt::skip] -fn estimate_v2_funding_transaction_fee( - funding_inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, - funding_feerate_sat_per_1000_weight: u32, -) -> u64 { - let input_weight: u64 = funding_inputs - .iter() - .map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight)) - .fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight)); - - let output_weight: u64 = outputs - .iter() - .map(|txout| txout.weight().to_wu()) - .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); - - let mut weight = input_weight.saturating_add(output_weight); - - // The initiator pays for all common fields and the shared output in the funding transaction. - if is_initiator { - weight = weight - .saturating_add(TX_COMMON_FIELDS_WEIGHT) - // The weight of the funding output, a P2WSH output - // NOTE: The witness script hash given here is irrelevant as it's a fixed size and we just want - // to calculate the contributed weight, so we use an all-zero hash. - .saturating_add(get_output_weight(&ScriptBuf::new_p2wsh( - &WScriptHash::from_raw_hash(Hash::all_zeros()) - )).to_wu()); - - // The splice initiator pays for the input spending the previous funding output. - if is_splice { - weight = weight - .saturating_add(BASE_INPUT_WEIGHT) - .saturating_add(EMPTY_SCRIPT_SIG_WEIGHT) - .saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); - #[cfg(feature = "grind_signatures")] - { - // Guarantees a low R signature - weight -= 1; - } - } - } - - fee_for_weight(funding_feerate_sat_per_1000_weight, weight) -} - -/// Verify that the provided inputs to the funding transaction are enough -/// to cover the intended contribution amount *plus* the proportional fees. -/// Fees are computed using `estimate_v2_funding_transaction_fee`, and contain -/// the fees of the inputs, fees of the inputs weight, and for the initiator, -/// the fees of the common fields as well as the output and extra input weights. -/// Returns estimated (partial) fees as additional information -#[rustfmt::skip] -fn check_v2_funding_inputs_sufficient( - contributed_input_value: Amount, funding_inputs: &[FundingTxInput], outputs: &[TxOut], - is_initiator: bool, is_splice: bool, funding_feerate_sat_per_1000_weight: u32, -) -> Result { - let estimated_fee = Amount::from_sat(estimate_v2_funding_transaction_fee( - funding_inputs, outputs, is_initiator, is_splice, funding_feerate_sat_per_1000_weight, - )); - - let mut total_input_value = Amount::ZERO; - for FundingTxInput { utxo, .. } in funding_inputs.iter() { - total_input_value = total_input_value.checked_add(utxo.output.value) - .ok_or("Sum of input values is greater than the total bitcoin supply")?; - } - - // If the inputs are enough to cover intended contribution amount, with fees even when - // there is a change output, we are fine. - // If the inputs are less, but enough to cover intended contribution amount, with - // (lower) fees with no change, we are also fine (change will not be generated). - // So it's enough to check considering the lower, no-change fees. - // - // Note: dust limit is not relevant in this check. - // - // TODO(splicing): refine check including the fact wether a change will be added or not. - // Can be done once dual funding preparation is included. - - let minimal_input_amount_needed = contributed_input_value.checked_add(estimated_fee) - .ok_or(format!("{contributed_input_value} contribution plus {estimated_fee} fee estimate exceeds the total bitcoin supply"))?; - if total_input_value < minimal_input_amount_needed { - Err(format!( - "Total input amount {total_input_value} is lower than needed for splice-in contribution {contributed_input_value}, considering fees of {estimated_fee}. Need more inputs.", - )) - } else { - Ok(estimated_fee) - } -} - /// Context for negotiating channels (dual-funded V2 open, splicing) #[derive(Debug)] pub(super) struct FundingNegotiationContext { @@ -7026,7 +6930,7 @@ where self.reset_pending_splice_state() } else { match self.quiescent_action.take() { - Some(QuiescentAction::Splice(instructions)) => { + Some(QuiescentAction::LegacySplice(instructions)) => { self.context.channel_state.clear_awaiting_quiescence(); let (inputs, outputs) = instructions.into_contributed_inputs_and_outputs(); Some(SpliceFundingFailed { @@ -7036,6 +6940,16 @@ where contributed_outputs: outputs, }) }, + Some(QuiescentAction::Splice { contribution, .. }) => { + self.context.channel_state.clear_awaiting_quiescence(); + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + Some(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: inputs, + contributed_outputs: outputs, + }) + }, #[cfg(any(test, fuzzing))] Some(quiescent_action) => { self.quiescent_action = Some(quiescent_action); @@ -11242,7 +11156,12 @@ where self.get_announcement_sigs(node_signer, chain_hash, user_config, block_height, logger); if let Some(quiescent_action) = self.quiescent_action.as_ref() { - if matches!(quiescent_action, QuiescentAction::Splice(_)) { + // TODO(splicing): If we didn't win quiescence, then we can contribute as an acceptor + // instead of waiting for the splice to lock. + if matches!( + quiescent_action, + QuiescentAction::Splice { .. } | QuiescentAction::LegacySplice(_) + ) { self.context.channel_state.set_awaiting_quiescence(); } } @@ -11891,10 +11810,9 @@ where /// Includes the witness weight for this input (e.g. P2WPKH_WITNESS_WEIGHT=109 for typical P2WPKH inputs). /// - `change_script`: an option change output script. If `None` and needed, one will be /// generated by `SignerProvider::get_destination_script`. - pub fn splice_channel( - &mut self, contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: u32, - logger: &L, - ) -> Result, APIError> { + pub fn splice_channel( + &mut self, contribution: SpliceContribution, feerate: FeeRate, + ) -> Result { if self.holder_commitment_point.current_point().is_none() { return Err(APIError::APIMisuseError { err: format!( @@ -11904,17 +11822,29 @@ where }); } - // Check if a splice has been initiated already. - // Note: only a single outstanding splice is supported (per spec) - if self.pending_splice.is_some() || self.quiescent_action.is_some() { + if self.quiescent_action.is_some() { return Err(APIError::APIMisuseError { err: format!( - "Channel {} cannot be spliced, as it has already a splice pending", + "Channel {} cannot be spliced as one is waiting to be negotiated", self.context.channel_id(), ), }); } + if let Some(pending_splice) = &self.pending_splice { + if let Some(funding_negotiation) = &pending_splice.funding_negotiation { + debug_assert!(self.context.channel_state.is_quiescent()); + if funding_negotiation.is_initiator() { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is currently being negotiated", + self.context.channel_id(), + ), + }); + } + } + } + if !self.context.is_usable() { return Err(APIError::APIMisuseError { err: format!( @@ -11934,71 +11864,74 @@ where }); } - // Fees for splice-out are paid from the channel balance whereas fees for splice-in - // are paid by the funding inputs. Therefore, in the case of splice-out, we add the - // fees on top of the user-specified contribution. We leave the user-specified - // contribution as-is for splice-ins. - let adjusted_funding_contribution = check_splice_contribution_sufficient( - &contribution, - true, - FeeRate::from_sat_per_kwu(u64::from(funding_feerate_per_kw)), - ) - .map_err(|e| APIError::APIMisuseError { - err: format!( - "Channel {} cannot be {}; {}", - self.context.channel_id(), - if our_funding_contribution.is_positive() { "spliced in" } else { "spliced out" }, - e - ), - })?; - // Note: post-splice channel value is not yet known at this point, counterparty contribution is not known // (Cannot test for miminum required post-splice channel value) let their_funding_contribution = SignedAmount::ZERO; - self.validate_splice_contributions( - adjusted_funding_contribution, - their_funding_contribution, - ) - .map_err(|err| APIError::APIMisuseError { err })?; - - for FundingTxInput { utxo, prevtx, .. } in contribution.inputs().iter() { - const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { - channel_id: ChannelId([0; 32]), - serial_id: 0, - prevtx: None, - prevtx_out: 0, - sequence: 0, - // Mutually exclusive with prevtx, which is accounted for below. - shared_input_txid: None, - }; - let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); - if message_len > LN_MAX_MSG_LEN { - return Err(APIError::APIMisuseError { - err: format!( - "Funding input references a prevtx that is too large for tx_add_input: {}", - utxo.outpoint, - ), - }); - } - } + self.validate_splice_contributions(our_funding_contribution, their_funding_contribution) + .map_err(|err| APIError::APIMisuseError { err })?; - let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); + let funding_txo = self.funding.get_funding_txo().expect("funding_txo should be set"); + let previous_utxo = + self.funding.get_funding_output().expect("funding_output should be set"); + let shared_input = Input { + outpoint: funding_txo.into_bitcoin_outpoint(), + previous_utxo, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + FUNDING_TRANSACTION_WITNESS_WEIGHT, + }; - let action = QuiescentAction::Splice(SpliceInstructions { - adjusted_funding_contribution, - our_funding_inputs, - our_funding_outputs, - change_script, - funding_feerate_per_kw, - locktime, - }); - self.propose_quiescence(logger, action) - .map_err(|e| APIError::APIMisuseError { err: e.to_owned() }) + Ok(FundingTemplate::for_splice(contribution, shared_input, feerate)) } - fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { - debug_assert!(self.pending_splice.is_none()); + pub fn funding_contributed( + &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, + ) -> Result, SpliceFundingFailed> { + debug_assert!(contribution.is_splice()); + + if let Err(e) = contribution.net_value().and_then(|our_funding_contribution| { + // For splice-out, our_funding_contribution is adjusted to cover fees if there + // aren't any inputs. + self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO) + }) { + log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e); + + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + + return Err(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + }); + } + + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( + |(e, action)| { + log_error!(logger, "{}", e); + // FIXME: Any better way to do this? + if let QuiescentAction::Splice { contribution, .. } = action { + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + } + } else { + debug_assert!(false); + SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs: vec![], + contributed_outputs: vec![], + } + } + }, + ) + } + fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { let SpliceInstructions { adjusted_funding_contribution, our_funding_inputs, @@ -12020,6 +11953,13 @@ where change_script, }; + self.send_splice_init_internal(context) + } + + fn send_splice_init_internal( + &mut self, context: FundingNegotiationContext, + ) -> msgs::SpliceInit { + debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak let prev_funding_txid = self.funding.get_funding_txid(); let funding_pubkey = match (prev_funding_txid, &self.context.holder_signer) { @@ -12034,6 +11974,10 @@ where _ => todo!(), }; + let funding_feerate_per_kw = context.funding_feerate_sat_per_1000_weight; + let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); + let locktime = context.funding_tx_locktime.to_consensus_u32(); + let funding_negotiation = FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; self.pending_splice = Some(PendingFunding { @@ -12045,7 +11989,7 @@ where msgs::SpliceInit { channel_id: self.context.channel_id, - funding_contribution_satoshis: adjusted_funding_contribution.to_sat(), + funding_contribution_satoshis, funding_feerate_per_kw, locktime, funding_pubkey, @@ -12112,7 +12056,7 @@ where } // TODO(splicing): Once splice acceptor can contribute, check that inputs are sufficient, - // similarly to the check in `splice_channel`. + // similarly to the check in `funding_contributed`. debug_assert_eq!(our_funding_contribution, SignedAmount::ZERO); let their_funding_contribution = SignedAmount::from_sat(msg.funding_contribution_satoshis); @@ -13099,14 +13043,14 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, &'static str> { + ) -> Result, (&'static str, QuiescentAction)> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { - return Err("Channel is not in a usable state to propose quiescence"); + return Err(("Channel is not in a usable state to propose quiescence", action)); } if self.quiescent_action.is_some() { - return Err("Channel already has a pending quiescent action and cannot start another"); + return Err(("Channel already has a pending quiescent action and cannot start another", action)); } self.quiescent_action = Some(action); @@ -13247,9 +13191,9 @@ where "Internal Error: Didn't have anything to do after reaching quiescence".to_owned() )); }, - Some(QuiescentAction::Splice(instructions)) => { + Some(QuiescentAction::LegacySplice(instructions)) => { if self.pending_splice.is_some() { - self.quiescent_action = Some(QuiescentAction::Splice(instructions)); + self.quiescent_action = Some(QuiescentAction::LegacySplice(instructions)); return Err(ChannelError::WarnAndDisconnect( format!( @@ -13262,6 +13206,52 @@ where let splice_init = self.send_splice_init(instructions); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, + Some(QuiescentAction::Splice { contribution, locktime }) => { + // TODO(splicing): If the splice has been negotiated but has not been locked, we + // can RBF here to add the contribution. + if self.pending_splice.is_some() { + self.quiescent_action = + Some(QuiescentAction::Splice { contribution, locktime }); + + return Err(ChannelError::WarnAndDisconnect( + format!( + "Channel {} cannot be spliced as it already has a splice pending", + self.context.channel_id(), + ), + )); + } + + let prev_funding_input = self.funding.to_splice_funding_input(); + let is_initiator = contribution.is_initiator(); + let our_funding_contribution = match contribution.net_value() { + Ok(net_value) => net_value, + Err(e) => { + debug_assert!(false); + return Err(ChannelError::WarnAndDisconnect( + format!( + "Internal Error: Insufficient funding contribution: {}", + e, + ) + )); + }, + }; + let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; + let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); + + let context = FundingNegotiationContext { + is_initiator, + our_funding_contribution, + funding_tx_locktime: locktime, + funding_feerate_sat_per_1000_weight: funding_feerate_per_kw, + shared_funding_input: Some(prev_funding_input), + our_funding_inputs, + our_funding_outputs, + change_script, + }; + + let splice_init = self.send_splice_init_internal(context); + return Ok(Some(StfuResponse::SpliceInit(splice_init))); + }, #[cfg(any(test, fuzzing))] Some(QuiescentAction::DoNothing) => { // In quiescence test we want to just hang out here, letting the test manually @@ -15824,7 +15814,6 @@ mod tests { }; use crate::ln::channel_keys::{RevocationBasepoint, RevocationKey}; use crate::ln::channelmanager::{self, HTLCSource, PaymentId}; - use crate::ln::funding::FundingTxInput; use crate::ln::msgs; use crate::ln::msgs::{ChannelUpdate, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::onion_utils::{AttributionData, LocalHTLCFailureReason}; @@ -15856,7 +15845,7 @@ mod tests { use bitcoin::secp256k1::{ecdsa::Signature, Secp256k1}; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::{Transaction, TxOut, Version}; - use bitcoin::{ScriptBuf, WPubkeyHash, WitnessProgram, WitnessVersion}; + use bitcoin::{WitnessProgram, WitnessVersion}; use std::cmp; #[test] @@ -18208,250 +18197,6 @@ mod tests { assert!(node_a_chan.check_get_channel_ready(0, &&logger).is_some()); } - #[test] - #[rustfmt::skip] - fn test_estimate_v2_funding_transaction_fee() { - use crate::ln::channel::estimate_v2_funding_transaction_fee; - - let one_input = [funding_input_sats(1_000)]; - let two_inputs = [funding_input_sats(1_000), funding_input_sats(1_000)]; - - // 2 inputs, initiator, 2000 sat/kw feerate - assert_eq!( - estimate_v2_funding_transaction_fee(&two_inputs, &[], true, false, 2000), - if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }, - ); - - // higher feerate - assert_eq!( - estimate_v2_funding_transaction_fee(&two_inputs, &[], true, false, 3000), - if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }, - ); - - // only 1 input - assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, &[], true, false, 2000), - if cfg!(feature = "grind_signatures") { 970 } else { 972 }, - ); - - // 0 inputs - assert_eq!( - estimate_v2_funding_transaction_fee(&[], &[], true, false, 2000), - 428, - ); - - // not initiator - assert_eq!( - estimate_v2_funding_transaction_fee(&[], &[], false, false, 2000), - 0, - ); - - // splice initiator - assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, &[], true, true, 2000), - if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }, - ); - - // splice acceptor - assert_eq!( - estimate_v2_funding_transaction_fee(&one_input, &[], false, true, 2000), - if cfg!(feature = "grind_signatures") { 542 } else { 544 }, - ); - } - - #[rustfmt::skip] - fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { - let prevout = TxOut { - value: Amount::from_sat(input_value_sats), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - }; - let prevtx = Transaction { - input: vec![], output: vec![prevout], - version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, - }; - - FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() - } - - fn funding_output_sats(output_value_sats: u64) -> TxOut { - TxOut { - value: Amount::from_sat(output_value_sats), - script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), - } - } - - #[test] - #[rustfmt::skip] - fn test_check_v2_funding_inputs_sufficient() { - use crate::ln::channel::check_v2_funding_inputs_sufficient; - - // positive case, inputs well over intended contribution - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // Net splice-in - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[ - funding_output_sats(200_000), - ], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // Net splice-out - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[ - funding_output_sats(400_000), - ], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // Net splice-out, inputs insufficient to cover fees - { - let expected_fee = if cfg!(feature = "grind_signatures") { 113670 } else { 113940 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[ - funding_output_sats(400_000), - ], - true, - true, - 90000, - ), - Err(format!( - "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // negative case, inputs clearly insufficient - { - let expected_fee = if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(220_000), - &[ - funding_input_sats(100_000), - ], - &[], - true, - true, - 2000, - ), - Err(format!( - "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // barely covers - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(300_000 - expected_fee - 20), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - true, - true, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - - // higher fee rate, does not cover - { - let expected_fee = if cfg!(feature = "grind_signatures") { 2506 } else { 2513 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(298032), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - true, - true, - 2200, - ), - Err(format!( - "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", - Amount::from_sat(expected_fee), - )), - ); - } - - // barely covers, less fees (no extra weight, not initiator) - { - let expected_fee = if cfg!(feature = "grind_signatures") { 1084 } else { 1088 }; - assert_eq!( - check_v2_funding_inputs_sufficient( - Amount::from_sat(300_000 - expected_fee - 20), - &[ - funding_input_sats(200_000), - funding_input_sats(100_000), - ], - &[], - false, - false, - 2000, - ).unwrap(), - Amount::from_sat(expected_fee), - ); - } - } - fn get_pre_and_post( pre_channel_value: u64, our_funding_contribution: i64, their_funding_contribution: i64, ) -> (u64, u64) { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 532514a3ae9..e732a553294 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -30,7 +30,7 @@ use bitcoin::hashes::{Hash, HashEngine, HmacEngine}; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; -use bitcoin::{secp256k1, Sequence, SignedAmount}; +use bitcoin::{secp256k1, FeeRate, Sequence, SignedAmount}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, @@ -63,7 +63,7 @@ use crate::ln::channel::{ WithChannelContext, }; use crate::ln::channel_state::ChannelDetails; -use crate::ln::funding::SpliceContribution; +use crate::ln::funding::{FundingContribution, SpliceContribution}; use crate::ln::inbound_payment; use crate::ln::interactivetxs::InteractiveTxMessageSend; use crate::ln::msgs; @@ -4538,14 +4538,14 @@ impl< /// /// Provide a `contribution` to determine if value is spliced in or out. The splice initiator is /// responsible for paying fees for common fields, shared inputs, and shared outputs along with - /// any contributed inputs and outputs. Fees are determined using `funding_feerate_per_kw` and - /// must be covered by the supplied inputs for splice-in or the channel balance for splice-out. - /// - /// An optional `locktime` for the funding transaction may be specified. If not given, the - /// current best block height is used. + /// any contributed inputs and outputs. Fees are determined using `feerate` and must be covered + /// by the supplied inputs for splice-in or the channel balance for splice-out. /// /// # Events /// + /// [`Event::FundingNeeded`] will be generated upon success and must be handled for the splice + /// to be initiated. + /// /// Once the funding transaction has been constructed, an [`Event::SplicePending`] will be /// emitted. At this point, any inputs contributed to the splice can only be re-spent if an /// [`Event::DiscardFunding`] is seen. @@ -4563,12 +4563,12 @@ impl< #[rustfmt::skip] pub fn splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: Option, + contribution: SpliceContribution, feerate: FeeRate, ) -> Result<(), APIError> { let mut res = Ok(()); PersistenceNotifierGuard::optionally_notify(self, || { let result = self.internal_splice_channel( - channel_id, counterparty_node_id, contribution, funding_feerate_per_kw, locktime + channel_id, counterparty_node_id, contribution, feerate, ); res = result; match res { @@ -4581,7 +4581,7 @@ impl< fn internal_splice_channel( &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - contribution: SpliceContribution, funding_feerate_per_kw: u32, locktime: Option, + contribution: SpliceContribution, feerate: FeeRate, ) -> Result<(), APIError> { let per_peer_state = self.per_peer_state.read().unwrap(); @@ -4609,21 +4609,42 @@ impl< // Look for the channel match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(mut chan_phase_entry) => { - let locktime = locktime.unwrap_or_else(|| self.current_best_block().height); if let Some(chan) = chan_phase_entry.get_mut().as_funded_mut() { - let logger = WithChannelContext::from(&self.logger, &chan.context, None); - let msg_opt = chan.splice_channel( - contribution, - funding_feerate_per_kw, - locktime, - &&logger, - )?; - if let Some(msg) = msg_opt { - peer_state.pending_msg_events.push(MessageSendEvent::SendStfu { - node_id: *counterparty_node_id, - msg, + let pending_events = self.pending_events.lock().unwrap(); + let awaiting_splice_funding = pending_events + .iter() + .find(|(event, _)| { + matches!( + event, + events::Event::FundingNeeded { + channel_id: splice_channel_id, + counterparty_node_id: splice_counterparty_node_id, + .. + } if splice_channel_id == channel_id && splice_counterparty_node_id == counterparty_node_id) + }) + .is_some(); + drop(pending_events); + + if awaiting_splice_funding { + return Err(APIError::APIMisuseError { + err: format!( + "Already awaiting splice funding for channel {}", + channel_id + ), }); } + + let funding_template = chan.splice_channel(contribution, feerate)?; + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::FundingNeeded { + channel_id: chan.context.channel_id(), + user_channel_id: chan.context.get_user_id(), + counterparty_node_id: chan.context.get_counterparty_node_id(), + funding_template, + }, + None, + )); Ok(()) } else { Err(APIError::ChannelUnavailable { @@ -6340,6 +6361,112 @@ impl< result } + /// Adds or removes funds from the given channel as specified by a [`FundingContribution`]. + /// + /// Used to handle an [`Event::FundingNeeded`] by constructing a [`FundingContribution`] from a + /// [`FundingTemplate`] and passing it here. See [`FundingTemplate::build`] and + /// [`FundingTemplate::build_sync`]. + /// + /// Calling this method will commence the process of creating a new funding transaction for the + /// channel. An [`Event::FundingTransactionReadyForSigning`] will be generated once the + /// transaction is successfully constructed interactively with the counterparty. + /// If unsuccessful, an [`Event::SpliceFailed`] will be surfaced instead. + /// + /// An optional `locktime` for the funding transaction may be specified. If not given, the + /// current best block height is used. + /// + /// Returns [`ChannelUnavailable`] when a channel is not found or an incorrect + /// `counterparty_node_id` is provided. + /// + /// Returns [`APIMisuseError`] when a channel is not in a state where it is expecting funding + /// contribution. + /// + /// [`FundingTemplate`]: crate::ln::funding::FundingTemplate + /// [`FundingTemplate::build`]: crate::ln::funding::FundingTemplate::build + /// [`FundingTemplate::build_sync`]: crate::ln::funding::FundingTemplate::build_sync + /// [`ChannelUnavailable`]: APIError::ChannelUnavailable + /// [`APIMisuseError`]: APIError::APIMisuseError + pub fn funding_contributed( + &self, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + contribution: FundingContribution, locktime: Option, + ) -> Result<(), APIError> { + let mut result = Ok(()); + PersistenceNotifierGuard::optionally_notify(self, || { + let per_peer_state = self.per_peer_state.read().unwrap(); + let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); + if peer_state_mutex_opt.is_none() { + result = Err(APIError::ChannelUnavailable { + err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") + }); + return NotifyOption::SkipPersistNoEvents; + } + + let mut peer_state = peer_state_mutex_opt.unwrap().lock().unwrap(); + + match peer_state.channel_by_id.get_mut(channel_id) { + Some(channel) => match channel.as_funded_mut() { + Some(chan) => { + let locktime = bitcoin::absolute::LockTime::from_consensus( + locktime.unwrap_or_else(|| self.current_best_block().height), + ); + let logger = WithChannelContext::from(&self.logger, chan.context(), None); + match chan.funding_contributed(contribution, locktime, &&logger) { + Ok(msg_opt) => { + if let Some(msg) = msg_opt { + peer_state.pending_msg_events.push( + MessageSendEvent::SendStfu { + node_id: *counterparty_node_id, + msg, + }, + ); + } + }, + Err(splice_funding_failed) => { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::SpliceFailed { + channel_id: *channel_id, + counterparty_node_id: *counterparty_node_id, + user_channel_id: channel.context().get_user_id(), + abandoned_funding_txo: splice_funding_failed.funding_txo, + channel_type: splice_funding_failed.channel_type.clone(), + contributed_inputs: splice_funding_failed + .contributed_inputs, + contributed_outputs: splice_funding_failed + .contributed_outputs, + }, + None, + )); + }, + } + + return NotifyOption::DoPersist; + }, + None => { + result = Err(APIError::APIMisuseError { + err: format!( + "Channel with id {} not expecting funding contribution", + channel_id + ), + }); + return NotifyOption::SkipPersistNoEvents; + }, + }, + None => { + result = Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} not found for the passed counterparty node_id {}", + channel_id, counterparty_node_id + ), + }); + return NotifyOption::SkipPersistNoEvents; + }, + } + }); + + result + } + /// Handles a signed funding transaction generated by interactive transaction construction and /// provided by the client. Should only be called in response to a [`FundingTransactionReadyForSigning`] /// event. @@ -13149,7 +13276,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err(msg) => log_trace!(logger, "{}", msg), + Err((msg, _action)) => log_trace!(logger, "{}", msg), } } else { result = Err(APIError::APIMisuseError { @@ -14171,6 +14298,17 @@ impl< events.pop_front().map(|(e, _)| e) } + #[cfg(any(test, feature = "_test_utils"))] + pub fn push_pending_msg_event( + &self, counterparty_node_id: &PublicKey, msg_event: MessageSendEvent, + ) { + let per_peer_state = self.per_peer_state.read().unwrap(); + if let Some(peer_state_mutex) = per_peer_state.get(counterparty_node_id) { + let mut peer_state = peer_state_mutex.lock().unwrap(); + peer_state.pending_msg_events.push(msg_event); + } + } + #[cfg(test)] pub fn has_pending_payments(&self) -> bool { self.pending_outbound_payments.has_pending_payments() diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 140e8f8fa58..9a3fdf47ade 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -404,10 +404,10 @@ fn do_connect_block_without_consistency_checks<'a, 'b, 'c, 'd>( } pub fn provide_anchor_reserves<'a, 'b, 'c>(nodes: &[Node<'a, 'b, 'c>]) -> Transaction { - provide_anchor_utxo_reserves(nodes, 1, Amount::ONE_BTC) + provide_utxo_reserves(nodes, 1, Amount::ONE_BTC) } -pub fn provide_anchor_utxo_reserves<'a, 'b, 'c>( +pub fn provide_utxo_reserves<'a, 'b, 'c>( nodes: &[Node<'a, 'b, 'c>], utxos: usize, amount: Amount, ) -> Transaction { let mut output = Vec::with_capacity(nodes.len()); @@ -614,6 +614,10 @@ impl<'a, 'b, 'c> Node<'a, 'b, 'c> { self.blocks.lock().unwrap()[height as usize].0.header } + pub fn provide_funding_utxos(&self, utxos: usize, amount: Amount) -> Transaction { + provide_utxo_reserves(core::slice::from_ref(self), utxos, amount) + } + /// Executes `enable_channel_signer_op` for every single signer operation for this channel. #[cfg(test)] pub fn enable_all_channel_signer_ops(&self, peer_id: &PublicKey, chan_id: &ChannelId) { diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 9981250b05e..7d8f6b040f2 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -9,29 +9,40 @@ //! Types pertaining to funding channels. -use alloc::vec::Vec; - -use bitcoin::{Amount, ScriptBuf, SignedAmount, TxOut}; -use bitcoin::{Script, Sequence, Transaction, Weight}; - -use crate::events::bump_transaction::Utxo; -use crate::ln::chan_utils::EMPTY_SCRIPT_SIG_WEIGHT; +use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; +use bitcoin::{ + Amount, FeeRate, OutPoint, Script, ScriptBuf, Sequence, SignedAmount, Transaction, TxOut, + WScriptHash, Weight, +}; + +use core::ops::Deref; + +use crate::events::bump_transaction::sync::CoinSelectionSourceSync; +use crate::events::bump_transaction::{CoinSelectionSource, Input, Utxo}; +use crate::ln::chan_utils::{ + make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, + FUNDING_TRANSACTION_WITNESS_WEIGHT, +}; +use crate::ln::interactivetxs::{get_output_weight, TX_COMMON_FIELDS_WEIGHT}; +use crate::ln::msgs; +use crate::ln::types::ChannelId; +use crate::ln::LN_MAX_MSG_LEN; +use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +use crate::util::async_poll::MaybeSend; /// The components of a splice's funding transaction that are contributed by one party. #[derive(Debug, Clone)] pub struct SpliceContribution { - /// The amount from [`inputs`] to contribute to the splice. + /// The amount of value to contribute from inputs to the splice's funding transaction. /// - /// [`inputs`]: Self::inputs + /// If `value_added` is [`Amount::ZERO`], then any fees will be deducted from the channel + /// balance instead of paid by inputs. value_added: Amount, - /// The inputs included in the splice's funding transaction to meet the contributed amount - /// plus fees. Any excess amount will be sent to a change output. - inputs: Vec, - - /// The outputs to include in the splice's funding transaction. The total value of all - /// outputs plus fees will be the amount that is removed. + /// The outputs to include in the splice's funding transaction, whose amounts will be deducted + /// from the channel balance. outputs: Vec, /// An optional change output script. This will be used if needed or, when not set, @@ -43,15 +54,13 @@ pub struct SpliceContribution { impl SpliceContribution { /// Creates a contribution for when funds are only added to a channel. - pub fn splice_in( - value_added: Amount, inputs: Vec, change_script: Option, - ) -> Self { - Self { value_added, inputs, outputs: vec![], change_script } + pub fn splice_in(value_added: Amount, change_script: Option) -> Self { + Self { value_added, outputs: vec![], change_script } } /// Creates a contribution for when funds are only removed from a channel. pub fn splice_out(outputs: Vec) -> Self { - Self { value_added: Amount::ZERO, inputs: vec![], outputs, change_script: None } + Self { value_added: Amount::ZERO, outputs, change_script: None } } /// Creates a contribution for when funds are both added to and removed from a channel. @@ -60,10 +69,9 @@ impl SpliceContribution { /// value removed by `outputs`. The net value contributed can be obtained by calling /// [`SpliceContribution::net_value`]. pub fn splice_in_and_out( - value_added: Amount, inputs: Vec, outputs: Vec, - change_script: Option, + value_added: Amount, outputs: Vec, change_script: Option, ) -> Self { - Self { value_added, inputs, outputs, change_script } + Self { value_added, outputs, change_script } } /// The net value contributed to a channel by the splice. If negative, more value will be @@ -80,23 +88,348 @@ impl SpliceContribution { value_added - value_removed } +} + +/// A template for contributing to a channel's splice funding transaction. +/// +/// This is included in an [`Event::FundingNeeded`] when a channel is ready to be spliced. It +/// contains information passed as a [`SpliceContribution`] to [`ChannelManager::splice_channel`]. +/// It must be converted to a [`FundingContribution`] and passed to +/// [`ChannelManager::funding_contributed`] in order to resume the splicing process. +/// +/// [`Event::FundingNeeded`]: crate::events::Event::FundingNeeded +/// [`ChannelManager::splice_channel`]: crate::ln::channelmanager::ChannelManager::splice_channel +/// [`ChannelManager::funding_contributed`]: crate::ln::channelmanager::ChannelManager::funding_contributed +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FundingTemplate { + /// The amount to contribute to the channel. + /// + /// If `value_added` is [`Amount::ZERO`], then any fees will be deducted from the channel + /// balance instead of paid by inputs. + value_added: Amount, + + /// The outputs to contribute to the funding transaction, excluding change. + outputs: Vec, + + /// An optional change output script. This will be used if needed or, when not set, + /// generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + change_script: Option, + + /// The shared input, which, if present indicates the funding template is for a splice funding + /// transaction. + shared_input: Option, + + /// The fee rate to use for coin selection. + feerate: FeeRate, + + /// Whether the contributor initiated the funding, and thus is responsible for fees incurred for + /// common fields and shared inputs and outputs. + is_initiator: bool, +} + +impl_writeable_tlv_based!(FundingTemplate, { + (1, value_added, required), + (3, outputs, optional_vec), + (5, change_script, option), + (7, shared_input, option), + (9, feerate, required), + (11, is_initiator, required), +}); + +impl FundingTemplate { + /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. + pub(super) fn for_splice( + contribution: SpliceContribution, shared_input: Input, feerate: FeeRate, + ) -> Self { + let SpliceContribution { value_added, outputs, change_script } = contribution; + Self { + value_added, + outputs, + change_script, + shared_input: Some(shared_input), + feerate, + is_initiator: true, + } + } +} + +macro_rules! build_funding_contribution { + ($self:ident, $wallet:ident, $($await:tt)*) => {{ + let FundingTemplate { value_added, outputs, change_script, shared_input, feerate, is_initiator } = $self; + + let value_removed = outputs.iter().map(|txout| txout.value).sum(); + let is_splice = shared_input.is_some(); + + let inputs = if value_added == Amount::ZERO { + vec![] + } else { + // Used for creating a redeem script for the new funding txo, since the funding pubkeys + // are unknown at this point. Only needed when selecting which UTXOs to include in the + // funding tx that would be sufficient to pay for fees. Hence, the value doesn't matter. + let dummy_pubkey = PublicKey::from_slice(&[2; 33]).unwrap(); + + let shared_output = bitcoin::TxOut { + value: shared_input + .as_ref() + .map(|shared_input| shared_input.previous_utxo.value) + .unwrap_or(Amount::ZERO) + .checked_add(value_added) + .ok_or(())? + .checked_sub(value_removed) + .ok_or(())?, + script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(), + }; - pub(super) fn value_added(&self) -> Amount { - self.value_added + let claim_id = None; + let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); + let selection = if outputs.is_empty() { + let must_pay_to = &[shared_output]; + $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + } else { + let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); + $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? + }; + selection.confirmed_utxos + }; + + // NOTE: Must NOT fail after UTXO selection + + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, is_initiator, is_splice, feerate); + + let contribution = FundingContribution { + value_added, + estimated_fee, + inputs, + outputs, + change_script, + feerate, + is_initiator, + is_splice, + }; + + Ok(contribution) + }}; +} + +impl FundingTemplate { + /// Creates a `FundingContribution` from the template by using `wallet` to perform coin + /// selection with the given fee rate. + pub async fn build(self, wallet: W) -> Result + where + W::Target: CoinSelectionSource + MaybeSend, + { + build_funding_contribution!(self, wallet, await) } - pub(super) fn inputs(&self) -> &[FundingTxInput] { - &self.inputs[..] + /// Creates a `FundingContribution` from the template by using `wallet` to perform coin + /// selection with the given fee rate. + pub fn build_sync(self, wallet: W) -> Result + where + W::Target: CoinSelectionSourceSync, + { + build_funding_contribution!(self, wallet,) } +} - pub(super) fn outputs(&self) -> &[TxOut] { - &self.outputs[..] +fn estimate_transaction_fee( + inputs: &[FundingTxInput], outputs: &[TxOut], is_initiator: bool, is_splice: bool, + feerate: FeeRate, +) -> Amount { + let input_weight: u64 = inputs + .iter() + .map(|input| BASE_INPUT_WEIGHT.saturating_add(input.utxo.satisfaction_weight)) + .fold(0, |total_weight, input_weight| total_weight.saturating_add(input_weight)); + + let output_weight: u64 = outputs + .iter() + .map(|txout| txout.weight().to_wu()) + .fold(0, |total_weight, output_weight| total_weight.saturating_add(output_weight)); + + let mut weight = input_weight.saturating_add(output_weight); + + // The initiator pays for all common fields and the shared output in the funding transaction. + if is_initiator { + weight = weight + .saturating_add(TX_COMMON_FIELDS_WEIGHT) + // The weight of the funding output, a P2WSH output + // NOTE: The witness script hash given here is irrelevant as it's a fixed size and we just want + // to calculate the contributed weight, so we use an all-zero hash. + .saturating_add( + get_output_weight(&ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash( + Hash::all_zeros(), + ))) + .to_wu(), + ); + + // The splice initiator pays for the input spending the previous funding output. + if is_splice { + weight = weight + .saturating_add(BASE_INPUT_WEIGHT) + .saturating_add(EMPTY_SCRIPT_SIG_WEIGHT) + .saturating_add(FUNDING_TRANSACTION_WITNESS_WEIGHT); + #[cfg(feature = "grind_signatures")] + { + // Guarantees a low R signature + weight -= 1; + } + } + } + + Weight::from_wu(weight) * feerate +} + +/// The components of a funding transaction contributed by one party. +#[derive(Debug, Clone)] +pub struct FundingContribution { + /// The amount to contribute to the channel. + /// + /// If `value_added` is [`Amount::ZERO`], then any fees will be deducted from the channel + /// balance instead of paid by `inputs`. + value_added: Amount, + + /// The estimate fees responsible to be paid for the contribution. + estimated_fee: Amount, + + /// The inputs included in the funding transaction to meet the contributed amount plus fees. Any + /// excess amount will be sent to a change output. + inputs: Vec, + + /// The outputs to include in the funding transaction. The total value of all outputs plus fees + /// will be the amount that is removed. + outputs: Vec, + + /// An optional change output script. This will be used if needed or, when not set, + /// generated using [`SignerProvider::get_destination_script`]. + /// + /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script + change_script: Option, + + /// The fee rate used to select `inputs`. + feerate: FeeRate, + + /// Whether the contributor initiated the funding, and thus is responsible for fees incurred for + /// common fields and shared inputs and outputs. + is_initiator: bool, + + /// Whether the contribution is for funding a splice. + is_splice: bool, +} + +impl_writeable_tlv_based!(FundingContribution, { + (1, value_added, required), + (3, estimated_fee, required), + (5, inputs, optional_vec), + (7, outputs, optional_vec), + (9, change_script, option), + (11, feerate, required), + (13, is_initiator, required), + (15, is_splice, required), +}); + +impl FundingContribution { + pub(super) fn feerate(&self) -> FeeRate { + self.feerate + } + + pub(super) fn is_initiator(&self) -> bool { + self.is_initiator + } + + pub(super) fn is_splice(&self) -> bool { + self.is_splice } pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { - let SpliceContribution { value_added: _, inputs, outputs, change_script } = self; + let FundingContribution { inputs, outputs, change_script, .. } = self; (inputs, outputs, change_script) } + + pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { + (self.inputs.into_iter().map(|input| input.utxo.outpoint).collect(), self.outputs) + } + + /// The net value contributed to a channel by the splice. If negative, more value will be + /// spliced out than spliced in. Fees will be deducted from the expected splice-out amount + /// if no inputs were included. + pub fn net_value(&self) -> Result { + for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() { + use crate::util::ser::Writeable; + const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput { + channel_id: ChannelId([0; 32]), + serial_id: 0, + prevtx: None, + prevtx_out: 0, + sequence: 0, + // Mutually exclusive with prevtx, which is accounted for below. + shared_input_txid: None, + }; + let message_len = MESSAGE_TEMPLATE.serialized_length() + prevtx.serialized_length(); + if message_len > LN_MAX_MSG_LEN { + return Err(format!( + "Funding input references a prevtx that is too large for tx_add_input: {}", + utxo.outpoint + )); + } + } + + // Fees for splice-out are paid from the channel balance whereas fees for splice-in + // are paid by the funding inputs. Therefore, in the case of splice-out, we add the + // fees on top of the user-specified contribution. We leave the user-specified + // contribution as-is for splice-ins. + if !self.inputs.is_empty() { + let mut total_input_value = Amount::ZERO; + for FundingTxInput { utxo, .. } in self.inputs.iter() { + total_input_value = total_input_value + .checked_add(utxo.output.value) + .ok_or("Sum of input values is greater than the total bitcoin supply")?; + } + + // If the inputs are enough to cover intended contribution amount, with fees even when + // there is a change output, we are fine. + // If the inputs are less, but enough to cover intended contribution amount, with + // (lower) fees with no change, we are also fine (change will not be generated). + // So it's enough to check considering the lower, no-change fees. + // + // Note: dust limit is not relevant in this check. + // + // TODO(splicing): refine check including the fact wether a change will be added or not. + // Can be done once dual funding preparation is included. + + let contributed_input_value = self.value_added; + let estimated_fee = self.estimated_fee; + let minimal_input_amount_needed = contributed_input_value + .checked_add(estimated_fee) + .ok_or(format!("{contributed_input_value} contribution plus {estimated_fee} fee estimate exceeds the total bitcoin supply"))?; + if total_input_value < minimal_input_amount_needed { + return Err(format!( + "Total input amount {total_input_value} is lower than needed for splice-in contribution {contributed_input_value}, considering fees of {estimated_fee}. Need more inputs.", + )); + } + } + + let unpaid_fees = if self.inputs.is_empty() { self.estimated_fee } else { Amount::ZERO } + .to_signed() + .expect("fees should never exceed Amount::MAX_MONEY"); + let value_added = self.value_added.to_signed().map_err(|_| "Value added too large")?; + let value_removed = self + .outputs + .iter() + .map(|txout| txout.value) + .sum::() + .to_signed() + .map_err(|_| "Value removed too large")?; + + let contribution_amount = value_added - value_removed; + let adjusted_contribution = contribution_amount.checked_sub(unpaid_fees).ok_or(format!( + "{} splice-out amount plus {} fee estimate exceeds the total bitcoin supply", + contribution_amount.unsigned_abs(), + self.estimated_fee, + ))?; + + Ok(adjusted_contribution) + } } /// An input to contribute to a channel's funding transaction either when using the v2 channel @@ -267,3 +600,260 @@ impl FundingTxInput { self.utxo.output } } + +#[cfg(test)] +mod tests { + use super::{estimate_transaction_fee, FundingContribution, FundingTxInput}; + use bitcoin::hashes::Hash; + use bitcoin::transaction::{Transaction, TxOut, Version}; + use bitcoin::{Amount, FeeRate, ScriptBuf, SignedAmount, WPubkeyHash}; + + #[test] + #[rustfmt::skip] + fn test_estimate_transaction_fee() { + let one_input = [funding_input_sats(1_000)]; + let two_inputs = [funding_input_sats(1_000), funding_input_sats(1_000)]; + + // 2 inputs, initiator, 2000 sat/kw feerate + assert_eq!( + estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1512 } else { 1516 }), + ); + + // higher feerate + assert_eq!( + estimate_transaction_fee(&two_inputs, &[], true, false, FeeRate::from_sat_per_kwu(3000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 2268 } else { 2274 }), + ); + + // only 1 input + assert_eq!( + estimate_transaction_fee(&one_input, &[], true, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 970 } else { 972 }), + ); + + // 0 inputs + assert_eq!( + estimate_transaction_fee(&[], &[], true, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(428), + ); + + // not initiator + assert_eq!( + estimate_transaction_fee(&[], &[], false, false, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(0), + ); + + // splice initiator + assert_eq!( + estimate_transaction_fee(&one_input, &[], true, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }), + ); + + // splice acceptor + assert_eq!( + estimate_transaction_fee(&one_input, &[], false, true, FeeRate::from_sat_per_kwu(2000)), + Amount::from_sat(if cfg!(feature = "grind_signatures") { 542 } else { 544 }), + ); + } + + #[rustfmt::skip] + fn funding_input_sats(input_value_sats: u64) -> FundingTxInput { + let prevout = TxOut { + value: Amount::from_sat(input_value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + }; + let prevtx = Transaction { + input: vec![], output: vec![prevout], + version: Version::TWO, lock_time: bitcoin::absolute::LockTime::ZERO, + }; + + FundingTxInput::new_p2wpkh(prevtx, 0).unwrap() + } + + fn funding_output_sats(output_value_sats: u64) -> TxOut { + TxOut { + value: Amount::from_sat(output_value_sats), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()), + } + } + + #[test] + #[rustfmt::skip] + fn test_check_v2_funding_inputs_sufficient() { + // positive case, inputs well over intended contribution + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + } + + // Net splice-in + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![ + funding_output_sats(200_000), + ], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 200_000))); + } + + // Net splice-out + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2526 } else { 2532 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![ + funding_output_sats(400_000), + ], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 400_000))); + } + + // Net splice-out, inputs insufficient to cover fees + { + let expected_fee = if cfg!(feature = "grind_signatures") { 113670 } else { 113940 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![ + funding_output_sats(400_000), + ], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(90000), + }; + assert_eq!( + contribution.net_value(), + Err(format!( + "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), + ); + } + + // negative case, inputs clearly insufficient + { + let expected_fee = if cfg!(feature = "grind_signatures") { 1736 } else { 1740 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(220_000), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!( + contribution.net_value(), + Err(format!( + "Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), + ); + } + + // barely covers + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2278 } else { 2284 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(300_000 - expected_fee - 20), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + } + + // higher fee rate, does not cover + { + let expected_fee = if cfg!(feature = "grind_signatures") { 2506 } else { 2513 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(298032), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: true, + is_splice: true, + feerate: FeeRate::from_sat_per_kwu(2200), + }; + assert_eq!( + contribution.net_value(), + Err(format!( + "Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.", + Amount::from_sat(expected_fee), + )), + ); + } + + // barely covers, less fees (no extra weight, not initiator) + { + let expected_fee = if cfg!(feature = "grind_signatures") { 1084 } else { 1088 }; + let contribution = FundingContribution { + value_added: Amount::from_sat(300_000 - expected_fee - 20), + estimated_fee: Amount::from_sat(expected_fee), + inputs: vec![ + funding_input_sats(200_000), + funding_input_sats(100_000), + ], + outputs: vec![], + change_script: None, + is_initiator: false, + is_splice: false, + feerate: FeeRate::from_sat_per_kwu(2000), + }; + assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap())); + } + } +} diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index cf93c6243c4..b0780e14b29 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -13,13 +13,13 @@ use crate::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW; use crate::chain::channelmonitor::{ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; -use crate::events::bump_transaction::sync::WalletSourceSync; +use crate::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; use crate::ln::channelmanager::{provided_init_features, PaymentId, BREAKDOWN_TIMEOUT}; use crate::ln::functional_test_utils::*; -use crate::ln::funding::{FundingTxInput, SpliceContribution}; +use crate::ln::funding::{FundingContribution, SpliceContribution}; use crate::ln::msgs::{self, BaseMessageHandler, ChannelMessageHandler, MessageSendEvent}; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; @@ -27,10 +27,14 @@ use crate::routing::router::{PaymentParameters, RouteParameters}; use crate::util::errors::APIError; use crate::util::ser::Writeable; +use crate::sync::Arc; + use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash}; +use bitcoin::{ + Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, +}; #[test] fn test_splicing_not_supported_api_error() { @@ -47,15 +51,11 @@ fn test_splicing_not_supported_api_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); - let bs_contribution = SpliceContribution::splice_in(Amount::ZERO, Vec::new(), None); + let bs_contribution = SpliceContribution::splice_in(Amount::ZERO, None); - let res = nodes[1].node.splice_channel( - &channel_id, - &node_id_0, - bs_contribution.clone(), - 0, // funding_feerate_per_kw, - None, // locktime - ); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + let res = + nodes[1].node.splice_channel(&channel_id, &node_id_0, bs_contribution.clone(), feerate); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support splicing")) @@ -76,13 +76,7 @@ fn test_splicing_not_supported_api_error() { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let res = nodes[1].node.splice_channel( - &channel_id, - &node_id_0, - bs_contribution, - 0, // funding_feerate_per_kw, - None, // locktime - ); + let res = nodes[1].node.splice_channel(&channel_id, &node_id_0, bs_contribution, feerate); match res { Err(APIError::ChannelUnavailable { err }) => { assert!(err.contains("Peer does not support quiescence, a splicing prerequisite")) @@ -102,65 +96,91 @@ fn test_v1_splice_in_negative_insufficient_inputs() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); // Amount being added to the channel through the splice-in - let splice_in_sats = 20_000; + let splice_in_value = Amount::from_sat(20_000); // Create additional inputs, but insufficient - let extra_splice_funding_input_sats = splice_in_sats - 1; - let funding_inputs = - create_dual_funding_utxos_with_prev_txs(&nodes[0], &[extra_splice_funding_input_sats]); + let extra_splice_funding_input = splice_in_value - Amount::ONE_SAT; - let contribution = - SpliceContribution::splice_in(Amount::from_sat(splice_in_sats), funding_inputs, None); + provide_utxo_reserves(&nodes, 1, extra_splice_funding_input); + + let contribution = SpliceContribution::splice_in(splice_in_value, None); + let feerate = FeeRate::from_sat_per_kwu(1024); // Initiate splice-in, with insufficient input contribution - let res = nodes[0].node.splice_channel( - &channel_id, - &nodes[1].node.get_our_node_id(), - contribution, - 1024, // funding_feerate_per_kw, - None, // locktime - ); - match res { - Err(APIError::APIMisuseError { err }) => { - assert!(err.contains("Need more inputs")) - }, - _ => panic!("Wrong error {:?}", res.err().unwrap()), - } + nodes[0] + .node + .splice_channel(&channel_id, &nodes[1].node.get_our_node_id(), contribution, feerate) + .unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + assert!(funding_template.build_sync(&wallet).is_err()); } pub fn negotiate_splice_tx<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: SpliceContribution, ) { - let new_funding_script = - complete_splice_handshake(initiator, acceptor, channel_id, initiator_contribution.clone()); + let funding_contribution = + initiate_splice(initiator, acceptor, channel_id, initiator_contribution); + let new_funding_script = complete_splice_handshake(initiator, acceptor); + complete_interactive_funding_negotiation( initiator, acceptor, channel_id, - initiator_contribution, + funding_contribution, new_funding_script, ); } -pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( +pub fn initiate_splice<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: SpliceContribution, -) -> ScriptBuf { - let node_id_initiator = initiator.node.get_our_node_id(); +) -> FundingContribution { + let node_id_acceptor = acceptor.node.get_our_node_id(); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + initiator + .node + .splice_channel(&channel_id, &node_id_acceptor, initiator_contribution, feerate) + .unwrap(); + + fund_splice(initiator, acceptor, channel_id) +} + +pub fn fund_splice<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, +) -> FundingContribution { let node_id_acceptor = acceptor.node.get_our_node_id(); + let event = get_event!(initiator, Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&initiator.wallet_source), initiator.logger); + let funding_contribution = funding_template.build_sync(&wallet).unwrap(); + let locktime = None; initiator .node - .splice_channel( - &channel_id, - &node_id_acceptor, - initiator_contribution, - FEERATE_FLOOR_SATS_PER_KW, - None, - ) + .funding_contributed(&channel_id, &node_id_acceptor, funding_contribution.clone(), locktime) .unwrap(); + funding_contribution +} + +pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( + initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, +) -> ScriptBuf { + let node_id_initiator = initiator.node.get_our_node_id(); + let node_id_acceptor = acceptor.node.get_our_node_id(); + let stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); acceptor.node.handle_stfu(node_id_initiator, &stfu_init); let stfu_ack = get_event_msg!(acceptor, MessageSendEvent::SendStfu, node_id_initiator); @@ -182,7 +202,7 @@ pub fn complete_splice_handshake<'a, 'b, 'c, 'd>( pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, - initiator_contribution: SpliceContribution, new_funding_script: ScriptBuf, + initiator_contribution: FundingContribution, new_funding_script: ScriptBuf, ) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -348,14 +368,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( 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, channel_id, initiator_contribution.clone()); + let funding_contribution = + initiate_splice(initiator, acceptor, channel_id, initiator_contribution); + let new_funding_script = complete_splice_handshake(initiator, acceptor); complete_interactive_funding_negotiation( initiator, acceptor, channel_id, - initiator_contribution, + funding_contribution, new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false); @@ -396,6 +417,15 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( node_b.node.handle_splice_locked(node_id_a, splice_locked_for_node_b); let mut msg_events = node_b.node.get_and_clear_pending_msg_events(); + + // If the acceptor had a pending QuiescentAction, store the stfu message so that it can be used + // later in complete_splice_handshake. + let node_b_stfu = msg_events + .last() + .filter(|event| matches!(event, MessageSendEvent::SendStfu { .. })) + .is_some() + .then(|| msg_events.pop().unwrap()); + assert_eq!(msg_events.len(), if is_0conf { 1 } else { 2 }, "{msg_events:?}"); if let MessageSendEvent::SendSpliceLocked { msg, .. } = msg_events.remove(0) { node_a.node.handle_splice_locked(node_id_b, &msg); @@ -436,6 +466,11 @@ pub fn lock_splice<'a, 'b, 'c, 'd>( } } + // Restore the popped stfu message so that it can be used in complete_splice_handshake. + if let Some(msg_event) = node_b_stfu { + node_b.node.push_pending_msg_event(&node_id_a, msg_event); + } + // Remove the corresponding outputs and transactions the chain source is watching for the // old funding as it is no longer being tracked. node_a @@ -490,16 +525,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -544,16 +570,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -603,16 +620,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -746,16 +754,7 @@ fn test_config_reject_inbound_splices() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -801,24 +800,18 @@ fn test_splice_in() { let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - let added_value = Amount::from_sat(initial_channel_value_sat * 2); + let utxo_value = added_value * 3 / 4; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = Amount::from_sat(321); - let initiator_contribution = SpliceContribution::splice_in( - added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], - Some(change_script.clone()), - ); + provide_utxo_reserves(&nodes, 2, utxo_value); + + let initiator_contribution = + SpliceContribution::splice_in(added_value, Some(change_script.clone())); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); - let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, expected_change, @@ -894,14 +887,12 @@ fn test_splice_in_and_out() { let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - // Contribute a net negative value, with fees taken from the contributed inputs and the // remaining value sent to change let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat; let added_value = Amount::from_sat(htlc_limit_msat / 1000); let removed_value = added_value * 2; + let utxo_value = added_value * 3 / 4; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { Amount::from_sat(383) @@ -911,12 +902,10 @@ fn test_splice_in_and_out() { assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); + provide_utxo_reserves(&nodes, 2, utxo_value); + let initiator_contribution = SpliceContribution::splice_in_and_out( added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], vec![ TxOut { value: removed_value / 2, @@ -931,7 +920,7 @@ fn test_splice_in_and_out() { ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); - let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, expected_change, @@ -950,13 +939,11 @@ fn test_splice_in_and_out() { assert!(htlc_limit_msat < added_value.to_sat() * 1000); let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - // Contribute a net positive value, with fees taken from the contributed inputs and the // remaining value sent to change let added_value = Amount::from_sat(initial_channel_value_sat * 2); let removed_value = added_value / 2; + let utxo_value = added_value * 3 / 4; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { Amount::from_sat(383) @@ -964,12 +951,13 @@ fn test_splice_in_and_out() { Amount::from_sat(384) }; + // Clear UTXOs so that the change output from the previous splice isn't considered + nodes[0].wallet_source.clear_utxos(); + + provide_utxo_reserves(&nodes, 2, utxo_value); + let initiator_contribution = SpliceContribution::splice_in_and_out( added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], vec![ TxOut { value: removed_value / 2, @@ -984,7 +972,7 @@ fn test_splice_in_and_out() { ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); - let expected_change = Amount::ONE_BTC * 2 - added_value - fees; + let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, expected_change, @@ -1002,20 +990,15 @@ fn test_splice_in_and_out() { assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat); - let coinbase_tx1 = provide_anchor_reserves(&nodes); - let coinbase_tx2 = provide_anchor_reserves(&nodes); - // Fail adding a net contribution value of zero let added_value = Amount::from_sat(initial_channel_value_sat * 2); let removed_value = added_value; let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); + provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); + let initiator_contribution = SpliceContribution::splice_in_and_out( added_value, - vec![ - FundingTxInput::new_p2wpkh(coinbase_tx1, 0).unwrap(), - FundingTxInput::new_p2wpkh(coinbase_tx2, 0).unwrap(), - ], vec![ TxOut { value: removed_value / 2, @@ -1028,14 +1011,14 @@ fn test_splice_in_and_out() { ], Some(change_script), ); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); assert_eq!( nodes[0].node.splice_channel( &channel_id, &nodes[1].node.get_our_node_id(), initiator_contribution, - FEERATE_FLOOR_SATS_PER_KW, - None, + feerate ), Err(APIError::APIMisuseError { err: format!("Channel {} cannot be spliced; contribution cannot be zero", channel_id), @@ -1043,6 +1026,104 @@ fn test_splice_in_and_out() { ); } +#[test] +fn test_fails_initiating_concurrent_splices() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = test_default_channel_config(); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + 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 node_0_id = nodes[0].node.get_our_node_id(); + let node_1_id = nodes[1].node.get_our_node_id(); + + provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); + + let contribution = SpliceContribution::splice_out(vec![TxOut { + value: Amount::from_sat(initial_channel_value_sat / 4), + script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), + }]); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + nodes[0].node.splice_channel(&channel_id, &node_1_id, contribution.clone(), feerate).unwrap(); + + assert_eq!( + nodes[0].node.splice_channel(&channel_id, &node_1_id, contribution.clone(), feerate), + Err(APIError::APIMisuseError { + err: format!("Already awaiting splice funding for channel {}", channel_id), + }), + ); + + let funding_contribution = fund_splice(&nodes[0], &nodes[1], channel_id); + + assert_eq!( + nodes[0].node.splice_channel(&channel_id, &node_1_id, contribution.clone(), feerate), + Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is waiting to be negotiated", + channel_id + ), + }), + ); + + let new_funding_script = complete_splice_handshake(&nodes[0], &nodes[1]); + + assert_eq!( + nodes[0].node.splice_channel(&channel_id, &node_1_id, contribution.clone(), feerate), + Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is currently being negotiated", + channel_id + ), + }), + ); + + // The acceptor can enqueue a quiescent action while the current splice is pending. + let added_value = Amount::from_sat(initial_channel_value_sat); + let pending_contribution = SpliceContribution::splice_in(added_value, None); + nodes[1].node.splice_channel(&channel_id, &node_0_id, pending_contribution, feerate).unwrap(); + let _ = fund_splice(&nodes[1], &nodes[0], channel_id); + + complete_interactive_funding_negotiation( + &nodes[0], + &nodes[1], + channel_id, + funding_contribution, + new_funding_script, + ); + + assert_eq!( + nodes[0].node.splice_channel(&channel_id, &node_1_id, contribution.clone(), feerate), + Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot be spliced as one is currently being negotiated", + channel_id + ), + }), + ); + + let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false); + assert!(splice_locked.is_none()); + + expect_splice_pending_event(&nodes[0], &node_1_id); + expect_splice_pending_event(&nodes[1], &node_0_id); + + // Now that the splice is pending, another splice may be initiated. + assert!(nodes[0].node.splice_channel(&channel_id, &node_1_id, contribution, feerate).is_ok()); + let _ = get_event!(nodes[0], Event::FundingNeeded); + + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // However, the acceptor had enqueued a quiescent action while the splice was pending, so it + // will now attempt to initiated quiescence. + let _ = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_0_id); +} + #[cfg(test)] #[derive(PartialEq)] enum SpliceStatus { @@ -1077,16 +1158,16 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let (_, _, channel_id, initial_funding_tx) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + let coinbase_tx = provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); // We want to have two HTLCs pending to make sure we can claim those sent before and after a // splice negotiation. let payment_amount = 1_000_000; let (preimage1, payment_hash1, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); + let splice_in_amount = initial_channel_capacity / 2; let initiator_contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx.clone(), 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); @@ -1573,32 +1654,18 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - nodes[0] - .node - .splice_channel( - &channel_id, - &node_id_1, - node_0_contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let node_0_funding_contribution = + initiate_splice(&nodes[0], &nodes[1], channel_id, node_0_contribution.clone()); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); let node_1_contribution = SpliceContribution::splice_out(vec![TxOut { value: Amount::from_sat(splice_out_sat), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]); - nodes[1] - .node - .splice_channel( - &channel_id, - &node_id_0, - node_1_contribution.clone(), - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let node_1_funding_contribution = + initiate_splice(&nodes[1], &nodes[0], channel_id, node_1_contribution.clone()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); if reload { @@ -1631,6 +1698,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { } reconnect_args.send_stfu = (true, true); reconnect_nodes(reconnect_args); + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1); assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); @@ -1654,7 +1722,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { &nodes[0], &nodes[1], channel_id, - node_0_contribution, + node_0_funding_contribution, new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], use_0conf); @@ -1793,7 +1861,7 @@ fn do_test_propose_splice_while_disconnected(reload: bool, use_0conf: bool) { &nodes[1], &nodes[0], channel_id, - node_1_contribution, + node_1_funding_contribution, new_funding_script, ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(&nodes[1], &nodes[0], use_0conf); @@ -1833,11 +1901,11 @@ fn disconnect_on_unexpected_interactive_tx_message() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; let contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); @@ -1872,17 +1940,18 @@ fn fail_splice_on_interactive_tx_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; let contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); // Fail during interactive-tx construction by having the acceptor echo back tx_add_input instead // of sending tx_complete. The failure occurs because the serial id will have the wrong parity. - let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); + let funding_contribution = initiate_splice(initiator, acceptor, channel_id, contribution); + let _ = complete_splice_handshake(initiator, acceptor); let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -1896,7 +1965,7 @@ fn fail_splice_on_interactive_tx_error() { match event { Event::SpliceFailed { contributed_inputs, .. } => { assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], contribution.inputs()[0].outpoint()); + assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); }, _ => panic!("Expected Event::SpliceFailed"), } @@ -1926,17 +1995,19 @@ fn fail_splice_on_tx_abort() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; let contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); // Fail during interactive-tx construction by having the acceptor send tx_abort instead of // tx_complete. - let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); + let funding_contribution = + initiate_splice(initiator, acceptor, channel_id, contribution.clone()); + let _ = complete_splice_handshake(initiator, acceptor); let tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -1953,7 +2024,7 @@ fn fail_splice_on_tx_abort() { match event { Event::SpliceFailed { contributed_inputs, .. } => { assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], contribution.inputs()[0].outpoint()); + assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); }, _ => panic!("Expected Event::SpliceFailed"), } @@ -1980,16 +2051,17 @@ fn fail_splice_on_channel_close() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let splice_in_amount = initial_channel_capacity / 2; let contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); // Close the channel before completion of interactive-tx construction. - let _ = complete_splice_handshake(initiator, acceptor, channel_id, contribution.clone()); + let _ = initiate_splice(initiator, acceptor, channel_id, contribution.clone()); + let _ = complete_splice_handshake(initiator, acceptor); let _tx_add_input = get_event_msg!(initiator, MessageSendEvent::SendTxAddInput, node_id_acceptor); @@ -2031,25 +2103,17 @@ fn fail_quiescent_action_on_channel_close() { let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0); - let coinbase_tx = provide_anchor_reserves(&nodes); let splice_in_amount = initial_channel_capacity / 2; + + provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); + let contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_amount), - vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], Some(nodes[0].wallet_source.get_change_script().unwrap()), ); // Close the channel before completion of STFU handshake. - initiator - .node - .splice_channel( - &channel_id, - &node_id_acceptor, - contribution, - FEERATE_FLOOR_SATS_PER_KW, - None, - ) - .unwrap(); + let _ = initiate_splice(initiator, acceptor, channel_id, contribution); let _stfu_init = get_event_msg!(initiator, MessageSendEvent::SendStfu, node_id_acceptor); diff --git a/lightning/src/ln/zero_fee_commitment_tests.rs b/lightning/src/ln/zero_fee_commitment_tests.rs index 2503ad81cde..785894c3cb0 100644 --- a/lightning/src/ln/zero_fee_commitment_tests.rs +++ b/lightning/src/ln/zero_fee_commitment_tests.rs @@ -131,7 +131,7 @@ fn test_htlc_claim_chunking() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &configs); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let coinbase_tx = provide_anchor_utxo_reserves(&nodes, 50, Amount::from_sat(500)); + let coinbase_tx = provide_utxo_reserves(&nodes, 50, Amount::from_sat(500)); const CHAN_CAPACITY: u64 = 10_000_000; let (_, _, chan_id, _funding_tx) = create_announced_chan_between_nodes_with_value( @@ -322,7 +322,7 @@ fn test_anchor_tx_too_big() { let node_a_id = nodes[0].node.get_our_node_id(); - let _coinbase_tx_a = provide_anchor_utxo_reserves(&nodes, 50, Amount::from_sat(500)); + let _coinbase_tx_a = provide_utxo_reserves(&nodes, 50, Amount::from_sat(500)); const CHAN_CAPACITY: u64 = 10_000_000; let (_, _, chan_id, _funding_tx) = create_announced_chan_between_nodes_with_value( diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index f821aa5afc0..4c9fdd7b28e 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -41,6 +41,7 @@ use bitcoin::secp256k1::ecdsa; use bitcoin::secp256k1::schnorr; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::transaction::{OutPoint, Transaction, TxOut}; +use bitcoin::FeeRate; use bitcoin::{consensus, Sequence, TxIn, Weight, Witness}; use dnssec_prover::rr::Name; @@ -1418,6 +1419,19 @@ impl Readable for Weight { } } +impl Writeable for FeeRate { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.to_sat_per_kwu().write(w) + } +} + +impl Readable for FeeRate { + fn read(r: &mut R) -> Result { + let sat_kwu: u64 = Readable::read(r)?; + Ok(FeeRate::from_sat_per_kwu(sat_kwu)) + } +} + impl Writeable for Txid { fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(&self[..]) From 7fd1398c25231d0220cf4042383991cae57ac7d6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 5 Feb 2026 09:20:04 -0600 Subject: [PATCH 11/21] f - drop FundingTemplate serialization --- lightning/src/ln/funding.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 7d8f6b040f2..8d1c29c4220 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -129,15 +129,6 @@ pub struct FundingTemplate { is_initiator: bool, } -impl_writeable_tlv_based!(FundingTemplate, { - (1, value_added, required), - (3, outputs, optional_vec), - (5, change_script, option), - (7, shared_input, option), - (9, feerate, required), - (11, is_initiator, required), -}); - impl FundingTemplate { /// Constructs a [`FundingTemplate`] for a splice using the provided shared input. pub(super) fn for_splice( From 9df198f961028fbe588bb4cc011d3874e965cfa6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 5 Feb 2026 09:23:08 -0600 Subject: [PATCH 12/21] f - add TODO(taproot) --- lightning/src/ln/funding.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 8d1c29c4220..e3ad6503e3d 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -247,6 +247,8 @@ fn estimate_transaction_fee( // The weight of the funding output, a P2WSH output // NOTE: The witness script hash given here is irrelevant as it's a fixed size and we just want // to calculate the contributed weight, so we use an all-zero hash. + // + // TODO(taproot): Needs to consider different weights based on channel type .saturating_add( get_output_weight(&ScriptBuf::new_p2wsh(&WScriptHash::from_raw_hash( Hash::all_zeros(), From bfa35289260e3864187725f078051bfdd3667c55 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 20 Jan 2026 12:08:20 -0600 Subject: [PATCH 13/21] Use CoinSelection::change_output when splicing Now that CoinSelection is used to fund a splice funding transaction, use that for determining of a change output should be used. Previously, the initiator could either provide a change script upfront or let LDK generate one using SignerProvider::get_destination_script. Since older versions may have serialized a SpliceInstruction without a change script while waiting on quiescence, LDK must still generate a change output in this case. --- fuzz/src/chanmon_consistency.rs | 8 +- fuzz/src/full_stack.rs | 1 - .../src/upgrade_downgrade_tests.rs | 3 +- lightning/src/ln/channel.rs | 140 ++++++++++-------- lightning/src/ln/funding.rs | 92 +++++------- lightning/src/ln/interactivetxs.rs | 1 - lightning/src/ln/splicing_tests.rs | 113 +++++++------- 7 files changed, 177 insertions(+), 181 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 9debfc3a6a5..1316e612916 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -1865,7 +1865,7 @@ pub fn do_test( }, 0xa0 => { - let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000)); let feerate_sat_per_kw = fee_estimators[0].ret_val.load(atomic::Ordering::Acquire); let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[0].splice_channel( @@ -1882,7 +1882,7 @@ pub fn do_test( } }, 0xa1 => { - let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000)); let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[1].splice_channel( @@ -1899,7 +1899,7 @@ pub fn do_test( } }, 0xa2 => { - let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000)); let feerate_sat_per_kw = fee_estimators[1].ret_val.load(atomic::Ordering::Acquire); let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[1].splice_channel( @@ -1916,7 +1916,7 @@ pub fn do_test( } }, 0xa3 => { - let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000), None); + let contribution = SpliceContribution::splice_in(Amount::from_sat(10_000)); let feerate_sat_per_kw = fee_estimators[2].ret_val.load(atomic::Ordering::Acquire); let feerate = FeeRate::from_sat_per_kwu(feerate_sat_per_kw as u64); if let Err(e) = nodes[2].splice_channel( diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index f114aca26cf..43ae06ab944 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -1034,7 +1034,6 @@ pub fn do_test(mut data: &[u8], logger: &Arc } let contribution = SpliceContribution::splice_in( Amount::from_sat(splice_in_sats.min(900_000)), // Cap at available funds minus fees - Some(wallet.get_change_script().unwrap()), ); let _ = channelmanager.splice_channel( &chan.channel_id, diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 8df670321be..8618b1e3718 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -455,7 +455,8 @@ fn do_test_0_1_htlc_forward_after_splice(fail_htlc: bool) { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - let splice_tx = splice_channel(&nodes[0], &nodes[1], ChannelId(chan_id_bytes_a), contribution); + let (splice_tx, _) = + splice_channel(&nodes[0], &nodes[1], ChannelId(chan_id_bytes_a), contribution); for node in nodes.iter() { mine_transaction(node, &splice_tx); connect_blocks(node, ANTI_REORG_DELAY - 1); diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d1a48925983..cc7998d13ce 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2814,6 +2814,7 @@ impl_writeable_tlv_based!(PendingFunding, { enum FundingNegotiation { AwaitingAck { context: FundingNegotiationContext, + change_strategy: ChangeStrategy, new_holder_funding_key: PublicKey, }, ConstructingTransaction { @@ -6587,10 +6588,17 @@ pub(super) struct FundingNegotiationContext { /// The funding outputs we will be contributing to the channel. #[allow(dead_code)] // TODO(dual_funding): Remove once contribution to V2 channels is enabled. pub our_funding_outputs: Vec, +} + +/// How the funding transaction's change is determined. +#[derive(Debug)] +pub(super) enum ChangeStrategy { + /// The change output, if any, is included in the FundingContribution's outputs. + FromCoinSelection, + /// The change output script. This will be used if needed or -- if not set -- generated using /// `SignerProvider::get_destination_script`. - #[allow(dead_code)] // TODO(splicing): Remove once splicing is enabled. - pub change_script: Option, + LegacyUserProvided(Option), } impl FundingNegotiationContext { @@ -6598,7 +6606,7 @@ impl FundingNegotiationContext { /// If error occurs, it is caused by our side, not the counterparty. fn into_interactive_tx_constructor( mut self, context: &ChannelContext, funding: &FundingScope, signer_provider: &SP, - entropy_source: &ES, holder_node_id: PublicKey, + entropy_source: &ES, holder_node_id: PublicKey, change_strategy: ChangeStrategy, ) -> Result { debug_assert_eq!( self.shared_funding_input.is_some(), @@ -6619,46 +6627,15 @@ impl FundingNegotiationContext { script_pubkey: funding.get_funding_redeemscript().to_p2wsh(), }; - // Optionally add change output - let change_value_opt = if !self.our_funding_inputs.is_empty() { - match calculate_change_output_value( - &self, - self.shared_funding_input.is_some(), - &shared_funding_output.script_pubkey, - context.holder_dust_limit_satoshis, - ) { - Ok(change_value_opt) => change_value_opt, - Err(reason) => { - return Err(self.into_negotiation_error(reason)); - }, - } - } else { - None - }; - - if let Some(change_value) = change_value_opt { - let change_script = if let Some(script) = self.change_script { - script - } else { - match signer_provider.get_destination_script(context.channel_keys_id) { - Ok(script) => script, - Err(_) => { - let reason = AbortReason::InternalError("Error getting change script"); - return Err(self.into_negotiation_error(reason)); - }, - } - }; - let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; - let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); - let change_output_fee = - fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); - let change_value_decreased_with_fee = - change_value.to_sat().saturating_sub(change_output_fee); - // Check dust limit again - if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { - change_output.value = Amount::from_sat(change_value_decreased_with_fee); - self.our_funding_outputs.push(change_output); - } + match self.calculate_change_output( + context, + signer_provider, + &shared_funding_output, + change_strategy, + ) { + Ok(Some(change_output)) => self.our_funding_outputs.push(change_output), + Ok(None) => {}, + Err(reason) => return Err(self.into_negotiation_error(reason)), } let constructor_args = InteractiveTxConstructorArgs { @@ -6680,6 +6657,52 @@ impl FundingNegotiationContext { InteractiveTxConstructor::new(constructor_args) } + fn calculate_change_output( + &self, context: &ChannelContext, signer_provider: &SP, shared_funding_output: &TxOut, + change_strategy: ChangeStrategy, + ) -> Result, AbortReason> { + if self.our_funding_inputs.is_empty() { + return Ok(None); + } + + let change_script = match change_strategy { + ChangeStrategy::FromCoinSelection => return Ok(None), + ChangeStrategy::LegacyUserProvided(change_script) => change_script, + }; + + let change_value = calculate_change_output_value( + &self, + self.shared_funding_input.is_some(), + &shared_funding_output.script_pubkey, + context.holder_dust_limit_satoshis, + )?; + + if let Some(change_value) = change_value { + let change_script = match change_script { + Some(script) => script, + None => match signer_provider.get_destination_script(context.channel_keys_id) { + Ok(script) => script, + Err(_) => { + return Err(AbortReason::InternalError("Error getting change script")) + }, + }, + }; + let mut change_output = TxOut { value: change_value, script_pubkey: change_script }; + let change_output_weight = get_output_weight(&change_output.script_pubkey).to_wu(); + let change_output_fee = + fee_for_weight(self.funding_feerate_sat_per_1000_weight, change_output_weight); + let change_value_decreased_with_fee = + change_value.to_sat().saturating_sub(change_output_fee); + // Check dust limit again + if change_value_decreased_with_fee > context.holder_dust_limit_satoshis { + change_output.value = Amount::from_sat(change_value_decreased_with_fee); + return Ok(Some(change_output)); + } + } + + Ok(None) + } + fn into_negotiation_error(self, reason: AbortReason) -> NegotiationError { let (contributed_inputs, contributed_outputs) = self.into_contributed_inputs_and_outputs(); NegotiationError { reason, contributed_inputs, contributed_outputs } @@ -11950,14 +11973,13 @@ where shared_funding_input: Some(prev_funding_input), our_funding_inputs, our_funding_outputs, - change_script, }; - self.send_splice_init_internal(context) + self.send_splice_init_internal(context, ChangeStrategy::LegacyUserProvided(change_script)) } fn send_splice_init_internal( - &mut self, context: FundingNegotiationContext, + &mut self, context: FundingNegotiationContext, change_strategy: ChangeStrategy, ) -> msgs::SpliceInit { debug_assert!(self.pending_splice.is_none()); // Rotate the funding pubkey using the prev_funding_txid as a tweak @@ -11978,8 +12000,11 @@ where let funding_contribution_satoshis = context.our_funding_contribution.to_sat(); let locktime = context.funding_tx_locktime.to_consensus_u32(); - let funding_negotiation = - FundingNegotiation::AwaitingAck { context, new_holder_funding_key: funding_pubkey }; + let funding_negotiation = FundingNegotiation::AwaitingAck { + context, + change_strategy, + new_holder_funding_key: funding_pubkey, + }; self.pending_splice = Some(PendingFunding { funding_negotiation: Some(funding_negotiation), negotiated_candidates: vec![], @@ -12205,7 +12230,6 @@ where shared_funding_input: Some(prev_funding_input), our_funding_inputs: Vec::new(), our_funding_outputs: Vec::new(), - change_script: None, }; let mut interactive_tx_constructor = funding_negotiation_context @@ -12215,6 +12239,8 @@ where signer_provider, entropy_source, holder_node_id.clone(), + // ChangeStrategy doesn't matter when no inputs are contributed + ChangeStrategy::FromCoinSelection, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12265,11 +12291,11 @@ where let pending_splice = self.pending_splice.as_mut().expect("We should have returned an error earlier!"); // TODO: Good candidate for a let else statement once MSRV >= 1.65 - let funding_negotiation_context = - if let Some(FundingNegotiation::AwaitingAck { context, .. }) = + let (funding_negotiation_context, change_strategy) = + if let Some(FundingNegotiation::AwaitingAck { context, change_strategy, .. }) = pending_splice.funding_negotiation.take() { - context + (context, change_strategy) } else { panic!("We should have returned an error earlier!"); }; @@ -12281,6 +12307,7 @@ where signer_provider, entropy_source, holder_node_id.clone(), + change_strategy, ) .map_err(|err| { ChannelError::WarnAndDisconnect(format!( @@ -12311,7 +12338,7 @@ where let (funding_negotiation_context, new_holder_funding_key) = match &pending_splice .funding_negotiation { - Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key }) => { + Some(FundingNegotiation::AwaitingAck { context, new_holder_funding_key, .. }) => { (context, new_holder_funding_key) }, Some(FundingNegotiation::ConstructingTransaction { .. }) @@ -13236,7 +13263,7 @@ where }, }; let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32; - let (our_funding_inputs, our_funding_outputs, change_script) = contribution.into_tx_parts(); + let (our_funding_inputs, our_funding_outputs) = contribution.into_tx_parts(); let context = FundingNegotiationContext { is_initiator, @@ -13246,10 +13273,9 @@ where shared_funding_input: Some(prev_funding_input), our_funding_inputs, our_funding_outputs, - change_script, }; - let splice_init = self.send_splice_init_internal(context); + let splice_init = self.send_splice_init_internal(context, ChangeStrategy::FromCoinSelection); return Ok(Some(StfuResponse::SpliceInit(splice_init))); }, #[cfg(any(test, fuzzing))] @@ -14033,7 +14059,6 @@ impl PendingV2Channel { shared_funding_input: None, our_funding_inputs: funding_inputs, our_funding_outputs: Vec::new(), - change_script: None, }; let chan = Self { funding, @@ -14180,7 +14205,6 @@ impl PendingV2Channel { shared_funding_input: None, our_funding_inputs: our_funding_inputs.clone(), our_funding_outputs: Vec::new(), - change_script: None, }; let shared_funding_output = TxOut { value: Amount::from_sat(funding.get_value_satoshis()), diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index e3ad6503e3d..f58c545b4a8 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -19,7 +19,7 @@ use bitcoin::{ use core::ops::Deref; use crate::events::bump_transaction::sync::CoinSelectionSourceSync; -use crate::events::bump_transaction::{CoinSelectionSource, Input, Utxo}; +use crate::events::bump_transaction::{CoinSelection, CoinSelectionSource, Input, Utxo}; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -44,23 +44,17 @@ pub struct SpliceContribution { /// The outputs to include in the splice's funding transaction, whose amounts will be deducted /// from the channel balance. outputs: Vec, - - /// An optional change output script. This will be used if needed or, when not set, - /// generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script - change_script: Option, } impl SpliceContribution { /// Creates a contribution for when funds are only added to a channel. - pub fn splice_in(value_added: Amount, change_script: Option) -> Self { - Self { value_added, outputs: vec![], change_script } + pub fn splice_in(value_added: Amount) -> Self { + Self { value_added, outputs: vec![] } } /// Creates a contribution for when funds are only removed from a channel. pub fn splice_out(outputs: Vec) -> Self { - Self { value_added: Amount::ZERO, outputs, change_script: None } + Self { value_added: Amount::ZERO, outputs } } /// Creates a contribution for when funds are both added to and removed from a channel. @@ -68,10 +62,8 @@ impl SpliceContribution { /// Note that `value_added` represents the value added by `inputs` but should not account for /// value removed by `outputs`. The net value contributed can be obtained by calling /// [`SpliceContribution::net_value`]. - pub fn splice_in_and_out( - value_added: Amount, outputs: Vec, change_script: Option, - ) -> Self { - Self { value_added, outputs, change_script } + pub fn splice_in_and_out(value_added: Amount, outputs: Vec) -> Self { + Self { value_added, outputs } } /// The net value contributed to a channel by the splice. If negative, more value will be @@ -111,12 +103,6 @@ pub struct FundingTemplate { /// The outputs to contribute to the funding transaction, excluding change. outputs: Vec, - /// An optional change output script. This will be used if needed or, when not set, - /// generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script - change_script: Option, - /// The shared input, which, if present indicates the funding template is for a splice funding /// transaction. shared_input: Option, @@ -134,27 +120,20 @@ impl FundingTemplate { pub(super) fn for_splice( contribution: SpliceContribution, shared_input: Input, feerate: FeeRate, ) -> Self { - let SpliceContribution { value_added, outputs, change_script } = contribution; - Self { - value_added, - outputs, - change_script, - shared_input: Some(shared_input), - feerate, - is_initiator: true, - } + let SpliceContribution { value_added, outputs } = contribution; + Self { value_added, outputs, shared_input: Some(shared_input), feerate, is_initiator: true } } } macro_rules! build_funding_contribution { ($self:ident, $wallet:ident, $($await:tt)*) => {{ - let FundingTemplate { value_added, outputs, change_script, shared_input, feerate, is_initiator } = $self; + let FundingTemplate { value_added, outputs, shared_input, feerate, is_initiator } = $self; let value_removed = outputs.iter().map(|txout| txout.value).sum(); let is_splice = shared_input.is_some(); - let inputs = if value_added == Amount::ZERO { - vec![] + let coin_selection = if value_added == Amount::ZERO { + CoinSelection { confirmed_utxos: vec![], change_output: None } } else { // Used for creating a redeem script for the new funding txo, since the funding pubkeys // are unknown at this point. Only needed when selecting which UTXOs to include in the @@ -175,18 +154,19 @@ macro_rules! build_funding_contribution { let claim_id = None; let must_spend = shared_input.map(|input| vec![input]).unwrap_or_default(); - let selection = if outputs.is_empty() { + if outputs.is_empty() { let must_pay_to = &[shared_output]; $wallet.select_confirmed_utxos(claim_id, must_spend, must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? } else { let must_pay_to: Vec<_> = outputs.iter().cloned().chain(core::iter::once(shared_output)).collect(); $wallet.select_confirmed_utxos(claim_id, must_spend, &must_pay_to, feerate.to_sat_per_kwu() as u32, u64::MAX)$(.$await)*? - }; - selection.confirmed_utxos + } }; // NOTE: Must NOT fail after UTXO selection + let CoinSelection { confirmed_utxos: inputs, change_output } = coin_selection; + let estimated_fee = estimate_transaction_fee(&inputs, &outputs, is_initiator, is_splice, feerate); let contribution = FundingContribution { @@ -194,7 +174,7 @@ macro_rules! build_funding_contribution { estimated_fee, inputs, outputs, - change_script, + change_output, feerate, is_initiator, is_splice, @@ -293,11 +273,8 @@ pub struct FundingContribution { /// will be the amount that is removed. outputs: Vec, - /// An optional change output script. This will be used if needed or, when not set, - /// generated using [`SignerProvider::get_destination_script`]. - /// - /// [`SignerProvider::get_destination_script`]: crate::sign::SignerProvider::get_destination_script - change_script: Option, + /// The output where any change will be sent. + change_output: Option, /// The fee rate used to select `inputs`. feerate: FeeRate, @@ -315,7 +292,7 @@ impl_writeable_tlv_based!(FundingContribution, { (3, estimated_fee, required), (5, inputs, optional_vec), (7, outputs, optional_vec), - (9, change_script, option), + (9, change_output, option), (11, feerate, required), (13, is_initiator, required), (15, is_splice, required), @@ -334,13 +311,20 @@ impl FundingContribution { self.is_splice } - pub(super) fn into_tx_parts(self) -> (Vec, Vec, Option) { - let FundingContribution { inputs, outputs, change_script, .. } = self; - (inputs, outputs, change_script) + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { + let FundingContribution { inputs, mut outputs, change_output, .. } = self; + + if let Some(change_output) = change_output { + outputs.push(change_output); + } + + (inputs, outputs) } pub(super) fn into_contributed_inputs_and_outputs(self) -> (Vec, Vec) { - (self.inputs.into_iter().map(|input| input.utxo.outpoint).collect(), self.outputs) + let (inputs, outputs) = self.into_tx_parts(); + + (inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs) } /// The net value contributed to a channel by the splice. If negative, more value will be @@ -685,7 +669,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -706,7 +690,7 @@ mod tests { outputs: vec![ funding_output_sats(200_000), ], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -727,7 +711,7 @@ mod tests { outputs: vec![ funding_output_sats(400_000), ], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -748,7 +732,7 @@ mod tests { outputs: vec![ funding_output_sats(400_000), ], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(90000), @@ -772,7 +756,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -797,7 +781,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2000), @@ -816,7 +800,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: true, is_splice: true, feerate: FeeRate::from_sat_per_kwu(2200), @@ -841,7 +825,7 @@ mod tests { funding_input_sats(100_000), ], outputs: vec![], - change_script: None, + change_output: None, is_initiator: false, is_splice: false, feerate: FeeRate::from_sat_per_kwu(2000), diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs index 3c47658e963..c5db1bcbe8a 100644 --- a/lightning/src/ln/interactivetxs.rs +++ b/lightning/src/ln/interactivetxs.rs @@ -3435,7 +3435,6 @@ mod tests { shared_funding_input: None, our_funding_inputs: inputs, our_funding_outputs: outputs, - change_script: None, }; let gross_change = total_inputs - total_outputs - context.our_funding_contribution.to_unsigned().unwrap(); diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b0780e14b29..0af420a87e4 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -29,12 +29,9 @@ use crate::util::ser::Writeable; use crate::sync::Arc; -use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::PublicKey; -use bitcoin::{ - Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, -}; +use bitcoin::{Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; #[test] fn test_splicing_not_supported_api_error() { @@ -51,7 +48,7 @@ fn test_splicing_not_supported_api_error() { let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); - let bs_contribution = SpliceContribution::splice_in(Amount::ZERO, None); + let bs_contribution = SpliceContribution::splice_in(Amount::ZERO); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); let res = @@ -103,7 +100,7 @@ fn test_v1_splice_in_negative_insufficient_inputs() { provide_utxo_reserves(&nodes, 1, extra_splice_funding_input); - let contribution = SpliceContribution::splice_in(splice_in_value, None); + let contribution = SpliceContribution::splice_in(splice_in_value); let feerate = FeeRate::from_sat_per_kwu(1024); // Initiate splice-in, with insufficient input contribution @@ -216,8 +213,7 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( }) .map(|channel| channel.funding_txo.unwrap()) .unwrap(); - let (initiator_inputs, initiator_outputs, initiator_change_script) = - initiator_contribution.into_tx_parts(); + let (initiator_inputs, initiator_outputs) = initiator_contribution.into_tx_parts(); let mut expected_initiator_inputs = initiator_inputs .iter() .map(|input| input.utxo.outpoint) @@ -227,7 +223,6 @@ pub fn complete_interactive_funding_negotiation<'a, 'b, 'c, 'd>( .into_iter() .map(|output| output.script_pubkey) .chain(core::iter::once(new_funding_script)) - .chain(initiator_change_script.into_iter()) .collect::>(); let mut acceptor_sent_tx_complete = false; @@ -364,7 +359,7 @@ pub fn sign_interactive_funding_tx<'a, 'b, 'c, 'd>( pub fn splice_channel<'a, 'b, 'c, 'd>( initiator: &'a Node<'b, 'c, 'd>, acceptor: &'a Node<'b, 'c, 'd>, channel_id: ChannelId, initiator_contribution: SpliceContribution, -) -> Transaction { +) -> (Transaction, ScriptBuf) { let node_id_initiator = initiator.node.get_our_node_id(); let node_id_acceptor = acceptor.node.get_our_node_id(); @@ -377,7 +372,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( acceptor, channel_id, funding_contribution, - new_funding_script, + new_funding_script.clone(), ); let (splice_tx, splice_locked) = sign_interactive_funding_tx(initiator, acceptor, false); assert!(splice_locked.is_none()); @@ -385,7 +380,7 @@ pub fn splice_channel<'a, 'b, 'c, 'd>( expect_splice_pending_event(initiator, &node_id_acceptor); expect_splice_pending_event(acceptor, &node_id_initiator); - splice_tx + (splice_tx, new_funding_script) } pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>( @@ -698,7 +693,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, contribution); if reload { let encoded_monitor_0 = get_monitor!(nodes[0], channel_id).encode(); @@ -802,18 +797,22 @@ fn test_splice_in() { let added_value = Amount::from_sat(initial_channel_value_sat * 2); let utxo_value = added_value * 3 / 4; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); - let fees = Amount::from_sat(321); + let fees = Amount::from_sat(322); provide_utxo_reserves(&nodes, 2, utxo_value); - let initiator_contribution = - SpliceContribution::splice_in(added_value, Some(change_script.clone())); + let initiator_contribution = SpliceContribution::splice_in(added_value); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let (splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( - splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + splice_tx + .output + .iter() + .find(|txout| txout.script_pubkey != new_funding_script) + .unwrap() + .value, expected_change, ); @@ -857,7 +856,7 @@ fn test_splice_out() { }, ]); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); mine_transaction(&nodes[0], &splice_tx); mine_transaction(&nodes[1], &splice_tx); @@ -893,11 +892,10 @@ fn test_splice_in_and_out() { let added_value = Amount::from_sat(htlc_limit_msat / 1000); let removed_value = added_value * 2; let utxo_value = added_value * 3 / 4; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(383) + Amount::from_sat(385) } else { - Amount::from_sat(384) + Amount::from_sat(385) }; assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000); @@ -916,13 +914,19 @@ fn test_splice_in_and_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ], - Some(change_script.clone()), ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let (splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( - splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + splice_tx + .output + .iter() + .filter(|txout| txout.value != removed_value / 2) + .find(|txout| txout.script_pubkey != new_funding_script) + .unwrap() + .value, expected_change, ); @@ -944,11 +948,10 @@ fn test_splice_in_and_out() { let added_value = Amount::from_sat(initial_channel_value_sat * 2); let removed_value = added_value / 2; let utxo_value = added_value * 3 / 4; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); let fees = if cfg!(feature = "grind_signatures") { - Amount::from_sat(383) + Amount::from_sat(385) } else { - Amount::from_sat(384) + Amount::from_sat(385) }; // Clear UTXOs so that the change output from the previous splice isn't considered @@ -968,13 +971,19 @@ fn test_splice_in_and_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ], - Some(change_script.clone()), ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let (splice_tx, new_funding_script) = + splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); let expected_change = utxo_value * 2 - added_value - fees; assert_eq!( - splice_tx.output.iter().find(|txout| txout.script_pubkey == change_script).unwrap().value, + splice_tx + .output + .iter() + .filter(|txout| txout.value != removed_value / 2) + .find(|txout| txout.script_pubkey != new_funding_script) + .unwrap() + .value, expected_change, ); @@ -993,7 +1002,6 @@ fn test_splice_in_and_out() { // Fail adding a net contribution value of zero let added_value = Amount::from_sat(initial_channel_value_sat * 2); let removed_value = added_value; - let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()); provide_utxo_reserves(&nodes, 2, Amount::ONE_BTC); @@ -1009,7 +1017,6 @@ fn test_splice_in_and_out() { script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }, ], - Some(change_script), ); let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); @@ -1083,7 +1090,7 @@ fn test_fails_initiating_concurrent_splices() { // The acceptor can enqueue a quiescent action while the current splice is pending. let added_value = Amount::from_sat(initial_channel_value_sat); - let pending_contribution = SpliceContribution::splice_in(added_value, None); + let pending_contribution = SpliceContribution::splice_in(added_value); nodes[1].node.splice_channel(&channel_id, &node_0_id, pending_contribution, feerate).unwrap(); let _ = fund_splice(&nodes[1], &nodes[0], channel_id); @@ -1166,11 +1173,8 @@ fn do_test_splice_commitment_broadcast(splice_status: SpliceStatus, claim_htlcs: let (preimage1, payment_hash1, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let splice_in_amount = initial_channel_capacity / 2; - let initiator_contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); - let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + let initiator_contribution = SpliceContribution::splice_in(Amount::from_sat(splice_in_amount)); + let (splice_tx, _) = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); let (preimage2, payment_hash2, ..) = route_payment(&nodes[0], &[&nodes[1]], payment_amount); let htlc_expiry = nodes[0].best_block_info().1 + TEST_FINAL_CLTV + LATENCY_GRACE_PERIOD_BLOCKS; @@ -1904,10 +1908,7 @@ fn disconnect_on_unexpected_interactive_tx_message() { provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + let contribution = SpliceContribution::splice_in(Amount::from_sat(splice_in_amount)); // Complete interactive-tx construction, but fail by having the acceptor send a duplicate // tx_complete instead of commitment_signed. @@ -1943,10 +1944,7 @@ fn fail_splice_on_interactive_tx_error() { provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + let contribution = SpliceContribution::splice_in(Amount::from_sat(splice_in_amount)); // Fail during interactive-tx construction by having the acceptor echo back tx_add_input instead // of sending tx_complete. The failure occurs because the serial id will have the wrong parity. @@ -1998,10 +1996,7 @@ fn fail_splice_on_tx_abort() { provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + let contribution = SpliceContribution::splice_in(Amount::from_sat(splice_in_amount)); // Fail during interactive-tx construction by having the acceptor send tx_abort instead of // tx_complete. @@ -2054,10 +2049,7 @@ fn fail_splice_on_channel_close() { provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); let splice_in_amount = initial_channel_capacity / 2; - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + let contribution = SpliceContribution::splice_in(Amount::from_sat(splice_in_amount)); // Close the channel before completion of interactive-tx construction. let _ = initiate_splice(initiator, acceptor, channel_id, contribution.clone()); @@ -2107,10 +2099,7 @@ fn fail_quiescent_action_on_channel_close() { provide_utxo_reserves(&nodes, 1, Amount::ONE_BTC); - let contribution = SpliceContribution::splice_in( - Amount::from_sat(splice_in_amount), - Some(nodes[0].wallet_source.get_change_script().unwrap()), - ); + let contribution = SpliceContribution::splice_in(Amount::from_sat(splice_in_amount)); // Close the channel before completion of STFU handshake. let _ = initiate_splice(initiator, acceptor, channel_id, contribution); @@ -2194,7 +2183,7 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - let splice_tx_0_1 = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); + let (splice_tx_0_1, _) = splice_channel(&nodes[0], &nodes[1], channel_id_0_1, contribution); for node in &nodes { mine_transaction(node, &splice_tx_0_1); } @@ -2203,7 +2192,7 @@ fn do_test_splice_with_inflight_htlc_forward_and_resolution(expire_scid_pre_forw value: Amount::from_sat(1_000), script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(), }]); - let splice_tx_1_2 = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); + let (splice_tx_1_2, _) = splice_channel(&nodes[1], &nodes[2], channel_id_1_2, contribution); for node in &nodes { mine_transaction(node, &splice_tx_1_2); } From ec2a43b8afd55ae4cd00379f22518b38355113dd Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 2 Feb 2026 09:52:57 -0600 Subject: [PATCH 14/21] Consistently log in propose_quiescence Instead of logging both inside propose_quiescence and at the call site, only log inside it. This simplifies the return type. --- lightning/src/ln/channel.rs | 14 +++++++++----- lightning/src/ln/channelmanager.rs | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index cc7998d13ce..f210b0b4c2c 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -11929,8 +11929,7 @@ where } self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( - |(e, action)| { - log_error!(logger, "{}", e); + |action| { // FIXME: Any better way to do this? if let QuiescentAction::Splice { contribution, .. } = action { let (contributed_inputs, contributed_outputs) = @@ -13070,14 +13069,19 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, (&'static str, QuiescentAction)> { + ) -> Result, QuiescentAction> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { - return Err(("Channel is not in a usable state to propose quiescence", action)); + log_error!(logger, "Channel is not in a usable state to propose quiescence"); + return Err(action); } if self.quiescent_action.is_some() { - return Err(("Channel already has a pending quiescent action and cannot start another", action)); + log_error!( + logger, + "Channel already has a pending quiescent action and cannot start another", + ); + return Err(action); } self.quiescent_action = Some(action); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e732a553294..be290f407de 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13276,7 +13276,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err((msg, _action)) => log_trace!(logger, "{}", msg), + Err(action) => log_trace!(logger, "Failed to propose quiescence for: {:?}", action), } } else { result = Err(APIError::APIMisuseError { From a5bf3f8c7e0f6d6e9b86ba9fcdf0066cdbe7d270 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 5 Feb 2026 09:33:48 -0600 Subject: [PATCH 15/21] f - log_debug --- lightning/src/ln/channel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index f210b0b4c2c..253486ab04d 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13073,11 +13073,11 @@ where log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { - log_error!(logger, "Channel is not in a usable state to propose quiescence"); + log_debug!(logger, "Channel is not in a usable state to propose quiescence"); return Err(action); } if self.quiescent_action.is_some() { - log_error!( + log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); From f16d6324467a6e792901b9bacd10ffd4ea60046c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Mon, 2 Feb 2026 14:02:04 -0600 Subject: [PATCH 16/21] Run rustfmt on fuzz --- fuzz/src/lsps_message.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 547a27b70ee..8371d1c5fc7 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -77,17 +77,20 @@ pub fn do_test(data: &[u8]) { genesis_block.header.time, )); - let liquidity_manager = Arc::new(LiquidityManagerSync::new( - Arc::clone(&keys_manager), - Arc::clone(&keys_manager), - Arc::clone(&manager), - None::>, - None, - kv_store, - Arc::clone(&tx_broadcaster), - None, - None, - ).unwrap()); + let liquidity_manager = Arc::new( + LiquidityManagerSync::new( + Arc::clone(&keys_manager), + Arc::clone(&keys_manager), + Arc::clone(&manager), + None::>, + None, + kv_store, + Arc::clone(&tx_broadcaster), + None, + None, + ) + .unwrap(), + ); let mut reader = data; if let Ok(Some(msg)) = liquidity_manager.read(LSPS_MESSAGE_TYPE_ID, &mut reader) { let secp = Secp256k1::signing_only(); From 5fd9d77db07c63e0a62b50b1d5f6595f0bbfdbd7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 16:57:45 -0600 Subject: [PATCH 17/21] Move wallet utils to dedicated module Wallet-related types were tightly coupled to bump_transaction, making them less accessible for other use cases like channel funding and splicing. Extract these utilities to a dedicated module for improved code organization and reusability across the codebase. Co-Authored-By: Claude Sonnet 4.5 --- lightning/src/events/bump_transaction/mod.rs | 525 +---------------- lightning/src/events/bump_transaction/sync.rs | 9 +- lightning/src/ln/channel.rs | 2 +- lightning/src/ln/funding.rs | 2 +- lightning/src/ln/zero_fee_commitment_tests.rs | 4 +- lightning/src/util/anchor_channel_reserves.rs | 2 +- lightning/src/util/mod.rs | 1 + lightning/src/util/test_utils.rs | 2 +- lightning/src/util/wallet_utils.rs | 549 ++++++++++++++++++ 9 files changed, 570 insertions(+), 526 deletions(-) create mode 100644 lightning/src/util/wallet_utils.rs diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 0c43a599443..971d35862c4 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -14,47 +14,34 @@ pub mod sync; use alloc::collections::BTreeMap; -use core::future::Future; use core::ops::Deref; use crate::chain::chaininterface::{ compute_feerate_sat_per_1000_weight, fee_for_weight, BroadcasterInterface, }; use crate::chain::ClaimId; -use crate::io_extras::sink; use crate::ln::chan_utils; use crate::ln::chan_utils::{ shared_anchor_script_pubkey, HTLCOutputInCommitment, ANCHOR_INPUT_WITNESS_WEIGHT, - BASE_INPUT_WEIGHT, BASE_TX_SIZE, EMPTY_SCRIPT_SIG_WEIGHT, EMPTY_WITNESS_WEIGHT, - HTLC_SUCCESS_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_SUCCESS_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, - HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, - P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT, + EMPTY_SCRIPT_SIG_WEIGHT, EMPTY_WITNESS_WEIGHT, HTLC_SUCCESS_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, + HTLC_SUCCESS_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, HTLC_TIMEOUT_INPUT_KEYED_ANCHOR_WITNESS_WEIGHT, + HTLC_TIMEOUT_INPUT_P2A_ANCHOR_WITNESS_WEIGHT, TRUC_CHILD_MAX_WEIGHT, TRUC_MAX_WEIGHT, }; -use crate::ln::funding::FundingTxInput; use crate::ln::types::ChannelId; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::{ - ChannelDerivationParameters, HTLCDescriptor, SignerProvider, P2TR_KEY_PATH_WITNESS_WEIGHT, - P2WPKH_WITNESS_WEIGHT, -}; -use crate::sync::Mutex; -use crate::util::async_poll::{MaybeSend, MaybeSync}; +use crate::sign::{ChannelDerivationParameters, HTLCDescriptor, SignerProvider}; use crate::util::logger::Logger; +use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, ConfirmedUtxo, Input}; use bitcoin::amount::Amount; -use bitcoin::consensus::Encodable; -use bitcoin::constants::WITNESS_SCALE_FACTOR; -use bitcoin::key::TweakedPublicKey; use bitcoin::locktime::absolute::LockTime; use bitcoin::policy::MAX_STANDARD_TX_WEIGHT; use bitcoin::secp256k1; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1}; use bitcoin::transaction::Version; -use bitcoin::{ - OutPoint, Psbt, PubkeyHash, ScriptBuf, Sequence, Transaction, TxIn, TxOut, WPubkeyHash, Witness, -}; +use bitcoin::{OutPoint, Psbt, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; /// A descriptor used to sign for a commitment transaction's anchor output. #[derive(Clone, Debug, PartialEq, Eq)] @@ -258,502 +245,6 @@ pub enum BumpTransactionEvent { }, } -/// An input that must be included in a transaction when performing coin selection through -/// [`CoinSelectionSource::select_confirmed_utxos`]. It is guaranteed to be a SegWit input, so it -/// must have an empty [`TxIn::script_sig`] when spent. -#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] -pub struct Input { - /// The unique identifier of the input. - pub outpoint: OutPoint, - /// The UTXO being spent by the input. - pub previous_utxo: TxOut, - /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and - /// [`TxIn::witness`], each with their lengths included, required to satisfy the output's - /// script. - pub satisfaction_weight: u64, -} - -impl_writeable_tlv_based!(Input, { - (1, outpoint, required), - (3, previous_utxo, required), - (5, satisfaction_weight, required), -}); - -/// An unspent transaction output that is available to spend resulting from a successful -/// [`CoinSelection`] attempt. -#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] -pub struct Utxo { - /// The unique identifier of the output. - pub outpoint: OutPoint, - /// The output to spend. - pub output: TxOut, - /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and [`TxIn::witness`], each - /// with their lengths included, required to satisfy the output's script. The weight consumed by - /// the input's `script_sig` must account for [`WITNESS_SCALE_FACTOR`]. - pub satisfaction_weight: u64, - /// The sequence number to use in the [`TxIn`] when spending the UTXO. - pub sequence: Sequence, -} - -impl_writeable_tlv_based!(Utxo, { - (1, outpoint, required), - (3, output, required), - (5, satisfaction_weight, required), - (7, sequence, (default_value, Sequence::ENABLE_RBF_NO_LOCKTIME)), -}); - -impl Utxo { - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a legacy P2PKH output. - pub fn new_p2pkh(outpoint: OutPoint, value: Amount, pubkey_hash: &PubkeyHash) -> Self { - let script_sig_size = 1 /* script_sig length */ + - 1 /* OP_PUSH73 */ + - 73 /* sig including sighash flag */ + - 1 /* OP_PUSH33 */ + - 33 /* pubkey */; - Self { - outpoint, - output: TxOut { value, script_pubkey: ScriptBuf::new_p2pkh(pubkey_hash) }, - satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + 1, /* empty witness */ - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } - - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a P2WPKH nested in P2SH output. - pub fn new_nested_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { - let script_sig_size = 1 /* script_sig length */ + - 1 /* OP_0 */ + - 1 /* OP_PUSH20 */ + - 20 /* pubkey_hash */; - Self { - outpoint, - output: TxOut { - value, - script_pubkey: ScriptBuf::new_p2sh( - &ScriptBuf::new_p2wpkh(pubkey_hash).script_hash(), - ), - }, - satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 - + P2WPKH_WITNESS_WEIGHT, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } - - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a SegWit v0 P2WPKH output. - pub fn new_v0_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { - Self { - outpoint, - output: TxOut { value, script_pubkey: ScriptBuf::new_p2wpkh(pubkey_hash) }, - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } - - /// Returns a `Utxo` with the `satisfaction_weight` estimate for a keypath spend of a SegWit v1 P2TR output. - pub fn new_v1_p2tr( - outpoint: OutPoint, value: Amount, tweaked_public_key: TweakedPublicKey, - ) -> Self { - Self { - outpoint, - output: TxOut { value, script_pubkey: ScriptBuf::new_p2tr_tweaked(tweaked_public_key) }, - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT, - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - } - } -} - -/// An unspent transaction output with at least one confirmation. -pub type ConfirmedUtxo = FundingTxInput; - -/// The result of a successful coin selection attempt for a transaction requiring additional UTXOs -/// to cover its fees. -#[derive(Clone, Debug)] -pub struct CoinSelection { - /// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction - /// requiring additional fees. - pub confirmed_utxos: Vec, - /// An additional output tracking whether any change remained after coin selection. This output - /// should always have a value above dust for its given `script_pubkey`. It should not be - /// spent until the transaction it belongs to confirms to ensure mempool descendant limits are - /// not met. This implies no other party should be able to spend it except us. - pub change_output: Option, -} - -impl CoinSelection { - fn satisfaction_weight(&self) -> u64 { - self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() - } - - fn input_amount(&self) -> Amount { - self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() - } -} - -/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can -/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, -/// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], -/// which can provide a default implementation of this trait when used with [`Wallet`]. -/// -/// For a synchronous version of this trait, see [`sync::CoinSelectionSourceSync`]. -/// -/// This is not exported to bindings users as async is only supported in Rust. -// Note that updates to documentation on this trait should be copied to the synchronous version. -pub trait CoinSelectionSource { - /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are - /// available to spend. Implementations are free to pick their coin selection algorithm of - /// choice, as long as the following requirements are met: - /// - /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction - /// throughout coin selection, but must not be returned as part of the result. - /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction - /// throughout coin selection. In some cases, like when funding an anchor transaction, this - /// set is empty. Implementations should ensure they handle this correctly on their end, - /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be - /// provided, in which case a zero-value empty OP_RETURN output can be used instead. - /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the - /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. - /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this - /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC - /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for - /// anchor transactions, we will try your coin selection again with the same input-output - /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions - /// cannot be downsized. - /// - /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of - /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require - /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and - /// delaying block inclusion. - /// - /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they - /// can be re-used within new fee-bumped iterations of the original claiming transaction, - /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a - /// transaction associated with it, and all of the available UTXOs have already been assigned to - /// other claims, implementations must be willing to double spend their UTXOs. The choice of - /// which UTXOs to double spend is left to the implementation, but it must strive to keep the - /// set of other claims being double spent to a minimum. - /// - /// If `claim_id` is not set, then the selection should be treated as if it were for a unique - /// claim, (i.e., it should avoid double spending as described above). - /// - /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> impl Future> + MaybeSend + 'a; - /// Signs and provides the full witness for all inputs within the transaction known to the - /// trait (i.e., any provided via [`CoinSelectionSource::select_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a; -} - -/// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to -/// provide a default implementation to [`CoinSelectionSource`]. -/// -/// For a synchronous version of this trait, see [`sync::WalletSourceSync`]. -/// -/// This is not exported to bindings users as async is only supported in Rust. -// Note that updates to documentation on this trait should be copied to the synchronous version. -pub trait WalletSource { - /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. - fn list_confirmed_utxos<'a>( - &'a self, - ) -> impl Future, ()>> + MaybeSend + 'a; - - /// Returns the previous transaction containing the UTXO referenced by the outpoint. - fn get_prevtx<'a>( - &'a self, outpoint: OutPoint, - ) -> impl Future> + MaybeSend + 'a; - - /// Returns a script to use for change above dust resulting from a successful coin selection - /// attempt. - fn get_change_script<'a>( - &'a self, - ) -> impl Future> + MaybeSend + 'a; - - /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within - /// the transaction known to the wallet (i.e., any provided via - /// [`WalletSource::list_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a; -} - -/// A wrapper over [`WalletSource`] that implements [`CoinSelectionSource`] by preferring UTXOs -/// that would avoid conflicting double spends. If not enough UTXOs are available to do so, -/// conflicting double spends may happen. -/// -/// For a synchronous version of this wrapper, see [`sync::WalletSync`]. -/// -/// This is not exported to bindings users as async is only supported in Rust. -// Note that updates to documentation on this struct should be copied to the synchronous version. -pub struct Wallet -where - W::Target: WalletSource + MaybeSend, -{ - source: W, - logger: L, - // TODO: Do we care about cleaning this up once the UTXOs have a confirmed spend? We can do so - // by checking whether any UTXOs that exist in the map are no longer returned in - // `list_confirmed_utxos`. - locked_utxos: Mutex>>, -} - -impl Wallet -where - W::Target: WalletSource + MaybeSend, -{ - /// Returns a new instance backed by the given [`WalletSource`] that serves as an implementation - /// of [`CoinSelectionSource`]. - pub fn new(source: W, logger: L) -> Self { - Self { source, logger, locked_utxos: Mutex::new(new_hash_map()) } - } - - /// Performs coin selection on the set of UTXOs obtained from - /// [`WalletSource::list_confirmed_utxos`]. Its algorithm can be described as "smallest - /// above-dust-after-spend first", with a slight twist: we may skip UTXOs that are above dust at - /// the target feerate after having spent them in a separate claim transaction if - /// `force_conflicting_utxo_spend` is unset to avoid producing conflicting transactions. If - /// `tolerate_high_network_feerates` is set, we'll attempt to spend UTXOs that contribute at - /// least 1 satoshi at the current feerate, otherwise, we'll only attempt to spend those which - /// contribute at least twice their fee. - async fn select_confirmed_utxos_internal( - &self, utxos: &[Utxo], claim_id: Option, force_conflicting_utxo_spend: bool, - tolerate_high_network_feerates: bool, target_feerate_sat_per_1000_weight: u32, - preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, - max_tx_weight: u64, - ) -> Result { - debug_assert!(!(claim_id.is_none() && force_conflicting_utxo_spend)); - - // P2WSH and P2TR outputs are both the heaviest-weight standard outputs at 34 bytes - let max_coin_selection_weight = max_tx_weight - .checked_sub(preexisting_tx_weight + P2WSH_TXOUT_WEIGHT) - .ok_or_else(|| { - log_debug!( - self.logger, - "max_tx_weight is too small to accommodate the preexisting tx weight plus a P2WSH/P2TR output" - ); - })?; - - let mut selected_amount; - let mut total_fees; - let mut selected_utxos; - { - let mut locked_utxos = self.locked_utxos.lock().unwrap(); - let mut eligible_utxos = utxos - .iter() - .filter_map(|utxo| { - if let Some(utxo_claim_id) = locked_utxos.get(&utxo.outpoint) { - if (utxo_claim_id.is_none() || *utxo_claim_id != claim_id) - && !force_conflicting_utxo_spend - { - log_trace!( - self.logger, - "Skipping UTXO {} to prevent conflicting spend", - utxo.outpoint - ); - return None; - } - } - let fee_to_spend_utxo = Amount::from_sat(fee_for_weight( - target_feerate_sat_per_1000_weight, - BASE_INPUT_WEIGHT + utxo.satisfaction_weight, - )); - let should_spend = if tolerate_high_network_feerates { - utxo.output.value > fee_to_spend_utxo - } else { - utxo.output.value >= fee_to_spend_utxo * 2 - }; - if should_spend { - Some((utxo, fee_to_spend_utxo)) - } else { - log_trace!( - self.logger, - "Skipping UTXO {} due to dust proximity after spend", - utxo.outpoint - ); - None - } - }) - .collect::>(); - eligible_utxos.sort_unstable_by_key(|(utxo, fee_to_spend_utxo)| { - utxo.output.value - *fee_to_spend_utxo - }); - - selected_amount = input_amount_sat; - total_fees = Amount::from_sat(fee_for_weight( - target_feerate_sat_per_1000_weight, - preexisting_tx_weight, - )); - selected_utxos = VecDeque::new(); - // Invariant: `selected_utxos_weight` is never greater than `max_coin_selection_weight` - let mut selected_utxos_weight = 0; - for (utxo, fee_to_spend_utxo) in eligible_utxos { - if selected_amount >= target_amount_sat + total_fees { - break; - } - // First skip any UTXOs with prohibitive satisfaction weights - if BASE_INPUT_WEIGHT + utxo.satisfaction_weight > max_coin_selection_weight { - continue; - } - // If adding this UTXO to `selected_utxos` would push us over the - // `max_coin_selection_weight`, remove UTXOs from the front to make room - // for this new UTXO. - while selected_utxos_weight + BASE_INPUT_WEIGHT + utxo.satisfaction_weight - > max_coin_selection_weight - && !selected_utxos.is_empty() - { - let (smallest_value_after_spend_utxo, fee_to_spend_utxo): (Utxo, Amount) = - selected_utxos.pop_front().unwrap(); - selected_amount -= smallest_value_after_spend_utxo.output.value; - total_fees -= fee_to_spend_utxo; - selected_utxos_weight -= - BASE_INPUT_WEIGHT + smallest_value_after_spend_utxo.satisfaction_weight; - } - selected_amount += utxo.output.value; - total_fees += fee_to_spend_utxo; - selected_utxos_weight += BASE_INPUT_WEIGHT + utxo.satisfaction_weight; - selected_utxos.push_back((utxo.clone(), fee_to_spend_utxo)); - } - if selected_amount < target_amount_sat + total_fees { - log_debug!( - self.logger, - "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", - target_feerate_sat_per_1000_weight, - max_coin_selection_weight, - ); - return Err(()); - } - // Once we've selected enough UTXOs to cover `target_amount_sat + total_fees`, - // we may be able to remove some small-value ones while still covering - // `target_amount_sat + total_fees`. - while !selected_utxos.is_empty() - && selected_amount - selected_utxos.front().unwrap().0.output.value - >= target_amount_sat + total_fees - selected_utxos.front().unwrap().1 - { - let (smallest_value_after_spend_utxo, fee_to_spend_utxo) = - selected_utxos.pop_front().unwrap(); - selected_amount -= smallest_value_after_spend_utxo.output.value; - total_fees -= fee_to_spend_utxo; - } - for (utxo, _) in &selected_utxos { - locked_utxos.insert(utxo.outpoint, claim_id); - } - } - - let remaining_amount = selected_amount - target_amount_sat - total_fees; - let change_script = self.source.get_change_script().await?; - let change_output_fee = fee_for_weight( - target_feerate_sat_per_1000_weight, - (8 /* value */ + change_script.consensus_encode(&mut sink()).unwrap() as u64) - * WITNESS_SCALE_FACTOR as u64, - ); - let change_output_amount = - Amount::from_sat(remaining_amount.to_sat().saturating_sub(change_output_fee)); - let change_output = if change_output_amount < change_script.minimal_non_dust() { - log_debug!(self.logger, "Coin selection attempt did not yield change output"); - None - } else { - Some(TxOut { script_pubkey: change_script, value: change_output_amount }) - }; - - let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); - for (utxo, _) in selected_utxos { - let prevtx = self.source.get_prevtx(utxo.outpoint).await?; - let prevtx_id = prevtx.compute_txid(); - if prevtx_id != utxo.outpoint.txid - || prevtx.output.get(utxo.outpoint.vout as usize).is_none() - { - log_error!( - self.logger, - "Tx {} from wallet source doesn't contain output referenced by outpoint: {}", - prevtx_id, - utxo.outpoint, - ); - return Err(()); - } - - confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx }); - } - - Ok(CoinSelection { confirmed_utxos, change_output }) - } -} - -impl CoinSelectionSource - for Wallet -where - W::Target: WalletSource + MaybeSend + MaybeSync, -{ - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> impl Future> + MaybeSend + 'a { - async move { - let utxos = self.source.list_confirmed_utxos().await?; - // TODO: Use fee estimation utils when we upgrade to bitcoin v0.30.0. - let total_output_size: u64 = must_pay_to - .iter() - .map( - |output| 8 /* value */ + 1 /* script len */ + output.script_pubkey.len() as u64, - ) - .sum(); - let total_satisfaction_weight: u64 = - must_spend.iter().map(|input| input.satisfaction_weight).sum(); - let total_input_weight = - (BASE_INPUT_WEIGHT * must_spend.len() as u64) + total_satisfaction_weight; - - let preexisting_tx_weight = SEGWIT_MARKER_FLAG_WEIGHT - + total_input_weight - + ((BASE_TX_SIZE + total_output_size) * WITNESS_SCALE_FACTOR as u64); - let input_amount_sat = must_spend.iter().map(|input| input.previous_utxo.value).sum(); - let target_amount_sat = must_pay_to.iter().map(|output| output.value).sum(); - - let configs = [(false, false), (false, true), (true, false), (true, true)]; - for (force_conflicting_utxo_spend, tolerate_high_network_feerates) in configs { - if claim_id.is_none() && force_conflicting_utxo_spend { - continue; - } - log_debug!( - self.logger, - "Attempting coin selection targeting {} sat/kW (force_conflicting_utxo_spend = {}, tolerate_high_network_feerates = {})", - target_feerate_sat_per_1000_weight, - force_conflicting_utxo_spend, - tolerate_high_network_feerates - ); - let attempt = self - .select_confirmed_utxos_internal( - &utxos, - claim_id, - force_conflicting_utxo_spend, - tolerate_high_network_feerates, - target_feerate_sat_per_1000_weight, - preexisting_tx_weight, - input_amount_sat, - target_amount_sat, - max_tx_weight, - ) - .await; - if attempt.is_ok() { - return attempt; - } - } - Err(()) - } - } - - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a { - self.source.sign_psbt(psbt) - } -} - /// A handler for [`Event::BumpTransaction`] events that sources confirmed UTXOs from a /// [`CoinSelectionSource`] to fee bump transactions via Child-Pays-For-Parent (CPFP) or /// Replace-By-Fee (RBF). @@ -1338,11 +829,15 @@ mod tests { use crate::ln::chan_utils::ChannelTransactionParameters; use crate::ln::channel::ANCHOR_OUTPUT_VALUE_SATOSHI; use crate::sign::KeysManager; + use crate::sync::Mutex; use crate::types::features::ChannelTypeFeatures; use crate::util::ser::Readable; use crate::util::test_utils::{TestBroadcaster, TestLogger}; + use crate::util::wallet_utils::Utxo; + use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::hex::FromHex; + use bitcoin::key::TweakedPublicKey; use bitcoin::{ Network, ScriptBuf, Transaction, WitnessProgram, WitnessVersion, XOnlyPublicKey, }; diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index 39088bb0e97..2d88b0187fa 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -20,14 +20,13 @@ use crate::prelude::*; use crate::sign::SignerProvider; use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; use crate::util::logger::Logger; +use crate::util::wallet_utils::{ + CoinSelection, CoinSelectionSource, Input, Utxo, Wallet, WalletSource, +}; use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut}; -use super::BumpTransactionEvent; -use super::{ - BumpTransactionEventHandler, CoinSelection, CoinSelectionSource, Input, Utxo, Wallet, - WalletSource, -}; +use super::{BumpTransactionEvent, BumpTransactionEventHandler}; /// An alternative to [`CoinSelectionSourceSync`] that can be implemented and used along /// [`WalletSync`] to provide a default implementation to [`CoinSelectionSourceSync`]. diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 253486ab04d..a079e07c8f3 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -36,7 +36,6 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::BestBlock; -use crate::events::bump_transaction::Input; use crate::events::{ClosureReason, FundingInfo}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ @@ -86,6 +85,7 @@ use crate::util::errors::APIError; use crate::util::logger::{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::wallet_utils::Input; use alloc::collections::{btree_map, BTreeMap}; diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index f58c545b4a8..8ad77046a4f 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -19,7 +19,6 @@ use bitcoin::{ use core::ops::Deref; use crate::events::bump_transaction::sync::CoinSelectionSourceSync; -use crate::events::bump_transaction::{CoinSelection, CoinSelectionSource, Input, Utxo}; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -31,6 +30,7 @@ use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::async_poll::MaybeSend; +use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, Input, Utxo}; /// The components of a splice's funding transaction that are contributed by one party. #[derive(Debug, Clone)] diff --git a/lightning/src/ln/zero_fee_commitment_tests.rs b/lightning/src/ln/zero_fee_commitment_tests.rs index 785894c3cb0..18e2083b87e 100644 --- a/lightning/src/ln/zero_fee_commitment_tests.rs +++ b/lightning/src/ln/zero_fee_commitment_tests.rs @@ -371,7 +371,7 @@ fn test_anchor_tx_too_big() { - EMPTY_WITNESS_WEIGHT - P2WSH_TXOUT_WEIGHT; nodes[1].logger.assert_log( - "lightning::events::bump_transaction", + "lightning::util::wallet_utils", format!( "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", FEERATE, max_coin_selection_weight @@ -405,7 +405,7 @@ fn test_anchor_tx_too_big() { assert_eq!(txns[1].input.len(), 2); assert_eq!(txns[1].output.len(), 1); nodes[1].logger.assert_log( - "lightning::events::bump_transaction", + "lightning::util::wallet_utils", format!( "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", FEERATE, max_coin_selection_weight diff --git a/lightning/src/util/anchor_channel_reserves.rs b/lightning/src/util/anchor_channel_reserves.rs index 25a0e7ca0ba..2c09ddd70a6 100644 --- a/lightning/src/util/anchor_channel_reserves.rs +++ b/lightning/src/util/anchor_channel_reserves.rs @@ -24,7 +24,6 @@ use crate::chain::chaininterface::FeeEstimator; use crate::chain::chainmonitor::ChainMonitor; use crate::chain::chainmonitor::Persist; use crate::chain::Filter; -use crate::events::bump_transaction::Utxo; use crate::ln::chan_utils::max_htlcs; use crate::ln::channelmanager::AChannelManager; use crate::prelude::new_hash_set; @@ -32,6 +31,7 @@ use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::EntropySource; use crate::types::features::ChannelTypeFeatures; use crate::util::logger::Logger; +use crate::util::wallet_utils::Utxo; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::Amount; use bitcoin::FeeRate; diff --git a/lightning/src/util/mod.rs b/lightning/src/util/mod.rs index dcbea904b51..75434fdabab 100644 --- a/lightning/src/util/mod.rs +++ b/lightning/src/util/mod.rs @@ -51,6 +51,7 @@ pub(crate) mod macro_logger; // These have to come after macro_logger to build pub mod config; pub mod logger; +pub mod wallet_utils; #[cfg(any(test, feature = "_test_utils"))] pub mod test_utils; diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index a8caa32dd1f..3d2eaf84bd7 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -22,7 +22,6 @@ use crate::chain::channelmonitor::{ use crate::chain::transaction::OutPoint; use crate::chain::WatchedOutput; use crate::events::bump_transaction::sync::WalletSourceSync; -use crate::events::bump_transaction::{ConfirmedUtxo, Utxo}; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; use crate::ln::channel_state::ChannelDetails; @@ -62,6 +61,7 @@ use crate::util::persist::{KVStore, KVStoreSync, MonitorName}; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use crate::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use crate::util::wakers::Notifier; +use crate::util::wallet_utils::{ConfirmedUtxo, Utxo}; use bitcoin::amount::Amount; use bitcoin::block::Block; diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs new file mode 100644 index 00000000000..22e164a0edf --- /dev/null +++ b/lightning/src/util/wallet_utils.rs @@ -0,0 +1,549 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Utilities for wallet integration with LDK. + +use core::future::Future; +use core::ops::Deref; + +use crate::chain::chaininterface::fee_for_weight; +use crate::chain::ClaimId; +use crate::io_extras::sink; +use crate::ln::chan_utils::{ + BASE_INPUT_WEIGHT, BASE_TX_SIZE, EMPTY_SCRIPT_SIG_WEIGHT, P2WSH_TXOUT_WEIGHT, + SEGWIT_MARKER_FLAG_WEIGHT, +}; +use crate::ln::funding::FundingTxInput; +use crate::prelude::*; +use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; +use crate::sync::Mutex; +use crate::util::async_poll::{MaybeSend, MaybeSync}; +use crate::util::hash_tables::{new_hash_map, HashMap}; +use crate::util::logger::Logger; + +use bitcoin::amount::Amount; +use bitcoin::consensus::Encodable; +use bitcoin::constants::WITNESS_SCALE_FACTOR; +use bitcoin::key::TweakedPublicKey; +use bitcoin::{OutPoint, Psbt, PubkeyHash, ScriptBuf, Sequence, Transaction, TxOut, WPubkeyHash}; + +/// An input that must be included in a transaction when performing coin selection through +/// [`CoinSelectionSource::select_confirmed_utxos`]. It is guaranteed to be a SegWit input, so it +/// must have an empty [`TxIn::script_sig`] when spent. +/// +/// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig +#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] +pub struct Input { + /// The unique identifier of the input. + pub outpoint: OutPoint, + /// The UTXO being spent by the input. + pub previous_utxo: TxOut, + /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and + /// [`TxIn::witness`], each with their lengths included, required to satisfy the output's + /// script. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + pub satisfaction_weight: u64, +} + +impl_writeable_tlv_based!(Input, { + (1, outpoint, required), + (3, previous_utxo, required), + (5, satisfaction_weight, required), +}); + +/// An unspent transaction output that is available to spend resulting from a successful +/// [`CoinSelection`] attempt. +#[derive(Clone, Debug, Hash, PartialOrd, Ord, PartialEq, Eq)] +pub struct Utxo { + /// The unique identifier of the output. + pub outpoint: OutPoint, + /// The output to spend. + pub output: TxOut, + /// The upper-bound weight consumed by the input's full [`TxIn::script_sig`] and [`TxIn::witness`], each + /// with their lengths included, required to satisfy the output's script. The weight consumed by + /// the input's `script_sig` must account for [`WITNESS_SCALE_FACTOR`]. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + pub satisfaction_weight: u64, + /// The sequence number to use in the [`TxIn`] when spending the UTXO. + /// + /// [`TxIn`]: bitcoin::TxIn + pub sequence: Sequence, +} + +impl_writeable_tlv_based!(Utxo, { + (1, outpoint, required), + (3, output, required), + (5, satisfaction_weight, required), + (7, sequence, (default_value, Sequence::ENABLE_RBF_NO_LOCKTIME)), +}); + +impl Utxo { + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a legacy P2PKH output. + pub fn new_p2pkh(outpoint: OutPoint, value: Amount, pubkey_hash: &PubkeyHash) -> Self { + let script_sig_size = 1 /* script_sig length */ + + 1 /* OP_PUSH73 */ + + 73 /* sig including sighash flag */ + + 1 /* OP_PUSH33 */ + + 33 /* pubkey */; + Self { + outpoint, + output: TxOut { value, script_pubkey: ScriptBuf::new_p2pkh(pubkey_hash) }, + satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + 1, /* empty witness */ + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } + + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a P2WPKH nested in P2SH output. + pub fn new_nested_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { + let script_sig_size = 1 /* script_sig length */ + + 1 /* OP_0 */ + + 1 /* OP_PUSH20 */ + + 20 /* pubkey_hash */; + Self { + outpoint, + output: TxOut { + value, + script_pubkey: ScriptBuf::new_p2sh( + &ScriptBuf::new_p2wpkh(pubkey_hash).script_hash(), + ), + }, + satisfaction_weight: script_sig_size * WITNESS_SCALE_FACTOR as u64 + + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } + + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a SegWit v0 P2WPKH output. + pub fn new_v0_p2wpkh(outpoint: OutPoint, value: Amount, pubkey_hash: &WPubkeyHash) -> Self { + Self { + outpoint, + output: TxOut { value, script_pubkey: ScriptBuf::new_p2wpkh(pubkey_hash) }, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2WPKH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } + + /// Returns a `Utxo` with the `satisfaction_weight` estimate for a keypath spend of a SegWit v1 P2TR output. + pub fn new_v1_p2tr( + outpoint: OutPoint, value: Amount, tweaked_public_key: TweakedPublicKey, + ) -> Self { + Self { + outpoint, + output: TxOut { value, script_pubkey: ScriptBuf::new_p2tr_tweaked(tweaked_public_key) }, + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + P2TR_KEY_PATH_WITNESS_WEIGHT, + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + } + } +} + +/// An unspent transaction output with at least one confirmation. +pub type ConfirmedUtxo = FundingTxInput; + +/// The result of a successful coin selection attempt for a transaction requiring additional UTXOs +/// to cover its fees. +#[derive(Clone, Debug)] +pub struct CoinSelection { + /// The set of UTXOs (with at least 1 confirmation) to spend and use within a transaction + /// requiring additional fees. + pub confirmed_utxos: Vec, + /// An additional output tracking whether any change remained after coin selection. This output + /// should always have a value above dust for its given `script_pubkey`. It should not be + /// spent until the transaction it belongs to confirms to ensure mempool descendant limits are + /// not met. This implies no other party should be able to spend it except us. + pub change_output: Option, +} + +impl CoinSelection { + pub(crate) fn satisfaction_weight(&self) -> u64 { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.satisfaction_weight).sum() + } + + pub(crate) fn input_amount(&self) -> Amount { + self.confirmed_utxos.iter().map(|ConfirmedUtxo { utxo, .. }| utxo.output.value).sum() + } +} + +/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can +/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, +/// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], +/// which can provide a default implementation of this trait when used with [`Wallet`]. +/// +/// For a synchronous version of this trait, see [`sync::CoinSelectionSourceSync`]. +/// +/// This is not exported to bindings users as async is only supported in Rust. +/// +/// [`sync::CoinSelectionSourceSync`]: crate::events::bump_transaction::sync::CoinSelectionSourceSync +// Note that updates to documentation on this trait should be copied to the synchronous version. +pub trait CoinSelectionSource { + /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are + /// available to spend. Implementations are free to pick their coin selection algorithm of + /// choice, as long as the following requirements are met: + /// + /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction + /// throughout coin selection, but must not be returned as part of the result. + /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction + /// throughout coin selection. In some cases, like when funding an anchor transaction, this + /// set is empty. Implementations should ensure they handle this correctly on their end, + /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be + /// provided, in which case a zero-value empty OP_RETURN output can be used instead. + /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the + /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. + /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this + /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC + /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for + /// anchor transactions, we will try your coin selection again with the same input-output + /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions + /// cannot be downsized. + /// + /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of + /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require + /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and + /// delaying block inclusion. + /// + /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they + /// can be re-used within new fee-bumped iterations of the original claiming transaction, + /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a + /// transaction associated with it, and all of the available UTXOs have already been assigned to + /// other claims, implementations must be willing to double spend their UTXOs. The choice of + /// which UTXOs to double spend is left to the implementation, but it must strive to keep the + /// set of other claims being double spent to a minimum. + /// + /// If `claim_id` is not set, then the selection should be treated as if it were for a unique + /// claim, (i.e., it should avoid double spending as described above). + /// + /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a; + /// Signs and provides the full witness for all inputs within the transaction known to the + /// trait (i.e., any provided via [`CoinSelectionSource::select_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a; +} + +/// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to +/// provide a default implementation to [`CoinSelectionSource`]. +/// +/// For a synchronous version of this trait, see [`sync::WalletSourceSync`]. +/// +/// This is not exported to bindings users as async is only supported in Rust. +/// +/// [`sync::WalletSourceSync`]: crate::events::bump_transaction::sync::WalletSourceSync +// Note that updates to documentation on this trait should be copied to the synchronous version. +pub trait WalletSource { + /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. + fn list_confirmed_utxos<'a>( + &'a self, + ) -> impl Future, ()>> + MaybeSend + 'a; + + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + MaybeSend + 'a; + + /// Returns a script to use for change above dust resulting from a successful coin selection + /// attempt. + fn get_change_script<'a>( + &'a self, + ) -> impl Future> + MaybeSend + 'a; + + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within + /// the transaction known to the wallet (i.e., any provided via + /// [`WalletSource::list_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a; +} + +/// A wrapper over [`WalletSource`] that implements [`CoinSelectionSource`] by preferring UTXOs +/// that would avoid conflicting double spends. If not enough UTXOs are available to do so, +/// conflicting double spends may happen. +/// +/// For a synchronous version of this wrapper, see [`sync::WalletSync`]. +/// +/// This is not exported to bindings users as async is only supported in Rust. +/// +/// [`sync::WalletSync`]: crate::events::bump_transaction::sync::WalletSync +// Note that updates to documentation on this struct should be copied to the synchronous version. +pub struct Wallet +where + W::Target: WalletSource + MaybeSend, +{ + source: W, + logger: L, + // TODO: Do we care about cleaning this up once the UTXOs have a confirmed spend? We can do so + // by checking whether any UTXOs that exist in the map are no longer returned in + // `list_confirmed_utxos`. + locked_utxos: Mutex>>, +} + +impl Wallet +where + W::Target: WalletSource + MaybeSend, +{ + /// Returns a new instance backed by the given [`WalletSource`] that serves as an implementation + /// of [`CoinSelectionSource`]. + pub fn new(source: W, logger: L) -> Self { + Self { source, logger, locked_utxos: Mutex::new(new_hash_map()) } + } + + /// Performs coin selection on the set of UTXOs obtained from + /// [`WalletSource::list_confirmed_utxos`]. Its algorithm can be described as "smallest + /// above-dust-after-spend first", with a slight twist: we may skip UTXOs that are above dust at + /// the target feerate after having spent them in a separate claim transaction if + /// `force_conflicting_utxo_spend` is unset to avoid producing conflicting transactions. If + /// `tolerate_high_network_feerates` is set, we'll attempt to spend UTXOs that contribute at + /// least 1 satoshi at the current feerate, otherwise, we'll only attempt to spend those which + /// contribute at least twice their fee. + async fn select_confirmed_utxos_internal( + &self, utxos: &[Utxo], claim_id: Option, force_conflicting_utxo_spend: bool, + tolerate_high_network_feerates: bool, target_feerate_sat_per_1000_weight: u32, + preexisting_tx_weight: u64, input_amount_sat: Amount, target_amount_sat: Amount, + max_tx_weight: u64, + ) -> Result { + debug_assert!(!(claim_id.is_none() && force_conflicting_utxo_spend)); + + // P2WSH and P2TR outputs are both the heaviest-weight standard outputs at 34 bytes + let max_coin_selection_weight = max_tx_weight + .checked_sub(preexisting_tx_weight + P2WSH_TXOUT_WEIGHT) + .ok_or_else(|| { + log_debug!( + self.logger, + "max_tx_weight is too small to accommodate the preexisting tx weight plus a P2WSH/P2TR output" + ); + })?; + + let mut selected_amount; + let mut total_fees; + let mut selected_utxos; + { + let mut locked_utxos = self.locked_utxos.lock().unwrap(); + let mut eligible_utxos = utxos + .iter() + .filter_map(|utxo| { + if let Some(utxo_claim_id) = locked_utxos.get(&utxo.outpoint) { + if (utxo_claim_id.is_none() || *utxo_claim_id != claim_id) + && !force_conflicting_utxo_spend + { + log_trace!( + self.logger, + "Skipping UTXO {} to prevent conflicting spend", + utxo.outpoint + ); + return None; + } + } + let fee_to_spend_utxo = Amount::from_sat(fee_for_weight( + target_feerate_sat_per_1000_weight, + BASE_INPUT_WEIGHT + utxo.satisfaction_weight, + )); + let should_spend = if tolerate_high_network_feerates { + utxo.output.value > fee_to_spend_utxo + } else { + utxo.output.value >= fee_to_spend_utxo * 2 + }; + if should_spend { + Some((utxo, fee_to_spend_utxo)) + } else { + log_trace!( + self.logger, + "Skipping UTXO {} due to dust proximity after spend", + utxo.outpoint + ); + None + } + }) + .collect::>(); + eligible_utxos.sort_unstable_by_key(|(utxo, fee_to_spend_utxo)| { + utxo.output.value - *fee_to_spend_utxo + }); + + selected_amount = input_amount_sat; + total_fees = Amount::from_sat(fee_for_weight( + target_feerate_sat_per_1000_weight, + preexisting_tx_weight, + )); + selected_utxos = VecDeque::new(); + // Invariant: `selected_utxos_weight` is never greater than `max_coin_selection_weight` + let mut selected_utxos_weight = 0; + for (utxo, fee_to_spend_utxo) in eligible_utxos { + if selected_amount >= target_amount_sat + total_fees { + break; + } + // First skip any UTXOs with prohibitive satisfaction weights + if BASE_INPUT_WEIGHT + utxo.satisfaction_weight > max_coin_selection_weight { + continue; + } + // If adding this UTXO to `selected_utxos` would push us over the + // `max_coin_selection_weight`, remove UTXOs from the front to make room + // for this new UTXO. + while selected_utxos_weight + BASE_INPUT_WEIGHT + utxo.satisfaction_weight + > max_coin_selection_weight + && !selected_utxos.is_empty() + { + let (smallest_value_after_spend_utxo, fee_to_spend_utxo): (Utxo, Amount) = + selected_utxos.pop_front().unwrap(); + selected_amount -= smallest_value_after_spend_utxo.output.value; + total_fees -= fee_to_spend_utxo; + selected_utxos_weight -= + BASE_INPUT_WEIGHT + smallest_value_after_spend_utxo.satisfaction_weight; + } + selected_amount += utxo.output.value; + total_fees += fee_to_spend_utxo; + selected_utxos_weight += BASE_INPUT_WEIGHT + utxo.satisfaction_weight; + selected_utxos.push_back((utxo.clone(), fee_to_spend_utxo)); + } + if selected_amount < target_amount_sat + total_fees { + log_debug!( + self.logger, + "Insufficient funds to meet target feerate {} sat/kW while remaining under {} WU", + target_feerate_sat_per_1000_weight, + max_coin_selection_weight, + ); + return Err(()); + } + // Once we've selected enough UTXOs to cover `target_amount_sat + total_fees`, + // we may be able to remove some small-value ones while still covering + // `target_amount_sat + total_fees`. + while !selected_utxos.is_empty() + && selected_amount - selected_utxos.front().unwrap().0.output.value + >= target_amount_sat + total_fees - selected_utxos.front().unwrap().1 + { + let (smallest_value_after_spend_utxo, fee_to_spend_utxo) = + selected_utxos.pop_front().unwrap(); + selected_amount -= smallest_value_after_spend_utxo.output.value; + total_fees -= fee_to_spend_utxo; + } + for (utxo, _) in &selected_utxos { + locked_utxos.insert(utxo.outpoint, claim_id); + } + } + + let remaining_amount = selected_amount - target_amount_sat - total_fees; + let change_script = self.source.get_change_script().await?; + let change_output_fee = fee_for_weight( + target_feerate_sat_per_1000_weight, + (8 /* value */ + change_script.consensus_encode(&mut sink()).unwrap() as u64) + * WITNESS_SCALE_FACTOR as u64, + ); + let change_output_amount = + Amount::from_sat(remaining_amount.to_sat().saturating_sub(change_output_fee)); + let change_output = if change_output_amount < change_script.minimal_non_dust() { + log_debug!(self.logger, "Coin selection attempt did not yield change output"); + None + } else { + Some(TxOut { script_pubkey: change_script, value: change_output_amount }) + }; + + let mut confirmed_utxos = Vec::with_capacity(selected_utxos.len()); + for (utxo, _) in selected_utxos { + let prevtx = self.source.get_prevtx(utxo.outpoint).await?; + let prevtx_id = prevtx.compute_txid(); + if prevtx_id != utxo.outpoint.txid + || prevtx.output.get(utxo.outpoint.vout as usize).is_none() + { + log_error!( + self.logger, + "Tx {} from wallet source doesn't contain output referenced by outpoint: {}", + prevtx_id, + utxo.outpoint, + ); + return Err(()); + } + + confirmed_utxos.push(ConfirmedUtxo { utxo, prevtx }); + } + + Ok(CoinSelection { confirmed_utxos, change_output }) + } +} + +impl CoinSelectionSource + for Wallet +where + W::Target: WalletSource + MaybeSend + MaybeSync, +{ + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a { + async move { + let utxos = self.source.list_confirmed_utxos().await?; + // TODO: Use fee estimation utils when we upgrade to bitcoin v0.30.0. + let total_output_size: u64 = must_pay_to + .iter() + .map( + |output| 8 /* value */ + 1 /* script len */ + output.script_pubkey.len() as u64, + ) + .sum(); + let total_satisfaction_weight: u64 = + must_spend.iter().map(|input| input.satisfaction_weight).sum(); + let total_input_weight = + (BASE_INPUT_WEIGHT * must_spend.len() as u64) + total_satisfaction_weight; + + let preexisting_tx_weight = SEGWIT_MARKER_FLAG_WEIGHT + + total_input_weight + + ((BASE_TX_SIZE + total_output_size) * WITNESS_SCALE_FACTOR as u64); + let input_amount_sat = must_spend.iter().map(|input| input.previous_utxo.value).sum(); + let target_amount_sat = must_pay_to.iter().map(|output| output.value).sum(); + + let configs = [(false, false), (false, true), (true, false), (true, true)]; + for (force_conflicting_utxo_spend, tolerate_high_network_feerates) in configs { + if claim_id.is_none() && force_conflicting_utxo_spend { + continue; + } + log_debug!( + self.logger, + "Attempting coin selection targeting {} sat/kW (force_conflicting_utxo_spend = {}, tolerate_high_network_feerates = {})", + target_feerate_sat_per_1000_weight, + force_conflicting_utxo_spend, + tolerate_high_network_feerates + ); + let attempt = self + .select_confirmed_utxos_internal( + &utxos, + claim_id, + force_conflicting_utxo_spend, + tolerate_high_network_feerates, + target_feerate_sat_per_1000_weight, + preexisting_tx_weight, + input_amount_sat, + target_amount_sat, + max_tx_weight, + ) + .await; + if attempt.is_ok() { + return attempt; + } + } + Err(()) + } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + self.source.sign_psbt(psbt) + } +} From c3f0bd991c79f7163906fa4256d998551884762a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 17:23:26 -0600 Subject: [PATCH 18/21] Move sync wallet utils to util::wallet_utils Synchronous wallet utilities were coupled to bump_transaction::sync, limiting their reusability for other features like channel funding and splicing which need synchronous wallet operations. Consolidate all wallet utilities in a single module for consistency and improved code organization. Co-Authored-By: Claude Sonnet 4.5 --- fuzz/src/chanmon_consistency.rs | 2 +- fuzz/src/full_stack.rs | 2 +- .../src/upgrade_downgrade_tests.rs | 2 +- lightning/src/events/bump_transaction/mod.rs | 5 +- lightning/src/events/bump_transaction/sync.rs | 249 +---------------- lightning/src/ln/functional_test_utils.rs | 5 +- lightning/src/ln/funding.rs | 5 +- lightning/src/ln/splicing_tests.rs | 2 +- lightning/src/util/test_utils.rs | 3 +- lightning/src/util/wallet_utils.rs | 255 +++++++++++++++++- 10 files changed, 259 insertions(+), 271 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 1316e612916..39f9bbab920 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -44,7 +44,6 @@ use lightning::chain::{ chainmonitor, channelmonitor, BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch, }; use lightning::events; -use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::ln::channel::{ FEE_SPIKE_BUFFER_FEE_INCREASE_MULTIPLE, MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS, }; @@ -80,6 +79,7 @@ use lightning::util::logger::Logger; use lightning::util::ser::{LengthReadable, ReadableArgs, Writeable, Writer}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; +use lightning::util::wallet_utils::{WalletSourceSync, WalletSync}; use lightning_invoice::RawBolt11Invoice; diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 43ae06ab944..ab40eac5629 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -40,7 +40,6 @@ use lightning::chain::chaininterface::{BroadcasterInterface, ConfirmationTarget, use lightning::chain::chainmonitor; use lightning::chain::transaction::OutPoint; use lightning::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen}; -use lightning::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use lightning::events::Event; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{ @@ -73,6 +72,7 @@ use lightning::util::logger::Logger; use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use lightning::util::test_utils::TestWalletSource; +use lightning::util::wallet_utils::{WalletSourceSync, WalletSync}; use lightning_invoice::RawBolt11Invoice; diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 8618b1e3718..4b085a57038 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -46,8 +46,8 @@ use lightning_0_0_125::routing::router as router_0_0_125; use lightning_0_0_125::util::ser::Writeable as _; use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; -use lightning::events::bump_transaction::sync::WalletSourceSync; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; +use lightning::util::wallet_utils::WalletSourceSync; use lightning::ln::functional_test_utils::*; use lightning::ln::funding::SpliceContribution; use lightning::ln::msgs::BaseMessageHandler as _; diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 971d35862c4..7356337cff7 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -822,9 +822,7 @@ where mod tests { use super::*; - use crate::events::bump_transaction::sync::{ - BumpTransactionEventHandlerSync, CoinSelectionSourceSync, - }; + use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::io::Cursor; use crate::ln::chan_utils::ChannelTransactionParameters; use crate::ln::channel::ANCHOR_OUTPUT_VALUE_SATOSHI; @@ -833,6 +831,7 @@ mod tests { use crate::types::features::ChannelTypeFeatures; use crate::util::ser::Readable; use crate::util::test_utils::{TestBroadcaster, TestLogger}; + use crate::util::wallet_utils::CoinSelectionSourceSync; use crate::util::wallet_utils::Utxo; use bitcoin::constants::WITNESS_SCALE_FACTOR; diff --git a/lightning/src/events/bump_transaction/sync.rs b/lightning/src/events/bump_transaction/sync.rs index 2d88b0187fa..f2e1be1590c 100644 --- a/lightning/src/events/bump_transaction/sync.rs +++ b/lightning/src/events/bump_transaction/sync.rs @@ -15,258 +15,13 @@ use core::pin::pin; use core::task; use crate::chain::chaininterface::BroadcasterInterface; -use crate::chain::ClaimId; -use crate::prelude::*; use crate::sign::SignerProvider; -use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; +use crate::util::async_poll::dummy_waker; use crate::util::logger::Logger; -use crate::util::wallet_utils::{ - CoinSelection, CoinSelectionSource, Input, Utxo, Wallet, WalletSource, -}; - -use bitcoin::{OutPoint, Psbt, ScriptBuf, Transaction, TxOut}; +use crate::util::wallet_utils::{CoinSelectionSourceSync, CoinSelectionSourceSyncWrapper}; use super::{BumpTransactionEvent, BumpTransactionEventHandler}; -/// An alternative to [`CoinSelectionSourceSync`] that can be implemented and used along -/// [`WalletSync`] to provide a default implementation to [`CoinSelectionSourceSync`]. -/// -/// For an asynchronous version of this trait, see [`WalletSource`]. -// Note that updates to documentation on this trait should be copied to the asynchronous version. -pub trait WalletSourceSync { - /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. - fn list_confirmed_utxos(&self) -> Result, ()>; - - /// Returns the previous transaction containing the UTXO referenced by the outpoint. - fn get_prevtx(&self, outpoint: OutPoint) -> Result; - - /// Returns a script to use for change above dust resulting from a successful coin selection - /// attempt. - fn get_change_script(&self) -> Result; - - /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within - /// the transaction known to the wallet (i.e., any provided via - /// [`WalletSource::list_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - /// - /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig - /// [`TxIn::witness`]: bitcoin::TxIn::witness - fn sign_psbt(&self, psbt: Psbt) -> Result; -} - -pub(crate) struct WalletSourceSyncWrapper(T) -where - T::Target: WalletSourceSync; - -// Implement `Deref` directly on WalletSourceSyncWrapper so that it can be used directly -// below, rather than via a wrapper. -impl Deref for WalletSourceSyncWrapper -where - T::Target: WalletSourceSync, -{ - type Target = Self; - fn deref(&self) -> &Self { - self - } -} - -impl WalletSource for WalletSourceSyncWrapper -where - T::Target: WalletSourceSync, -{ - fn list_confirmed_utxos<'a>( - &'a self, - ) -> impl Future, ()>> + MaybeSend + 'a { - let utxos = self.0.list_confirmed_utxos(); - async move { utxos } - } - - fn get_prevtx<'a>( - &'a self, outpoint: OutPoint, - ) -> impl Future> + MaybeSend + 'a { - let prevtx = self.0.get_prevtx(outpoint); - Box::pin(async move { prevtx }) - } - - fn get_change_script<'a>( - &'a self, - ) -> impl Future> + MaybeSend + 'a { - let script = self.0.get_change_script(); - async move { script } - } - - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a { - let signed_psbt = self.0.sign_psbt(psbt); - async move { signed_psbt } - } -} - -/// A wrapper over [`WalletSourceSync`] that implements [`CoinSelectionSourceSync`] by preferring -/// UTXOs that would avoid conflicting double spends. If not enough UTXOs are available to do so, -/// conflicting double spends may happen. -/// -/// For an asynchronous version of this wrapper, see [`Wallet`]. -// Note that updates to documentation on this struct should be copied to the asynchronous version. -pub struct WalletSync -where - W::Target: WalletSourceSync + MaybeSend, -{ - wallet: Wallet, L>, -} - -impl WalletSync -where - W::Target: WalletSourceSync + MaybeSend, -{ - /// Constructs a new [`WalletSync`] instance. - pub fn new(source: W, logger: L) -> Self { - Self { wallet: Wallet::new(WalletSourceSyncWrapper(source), logger) } - } -} - -impl CoinSelectionSourceSync - for WalletSync -where - W::Target: WalletSourceSync + MaybeSend + MaybeSync, -{ - fn select_confirmed_utxos( - &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> Result { - let fut = self.wallet.select_confirmed_utxos( - claim_id, - must_spend, - must_pay_to, - target_feerate_sat_per_1000_weight, - max_tx_weight, - ); - let mut waker = dummy_waker(); - let mut ctx = task::Context::from_waker(&mut waker); - match pin!(fut).poll(&mut ctx) { - task::Poll::Ready(result) => result, - task::Poll::Pending => { - unreachable!( - "Wallet::select_confirmed_utxos should not be pending in a sync context" - ); - }, - } - } - - fn sign_psbt(&self, psbt: Psbt) -> Result { - let fut = self.wallet.sign_psbt(psbt); - let mut waker = dummy_waker(); - let mut ctx = task::Context::from_waker(&mut waker); - match pin!(fut).poll(&mut ctx) { - task::Poll::Ready(result) => result, - task::Poll::Pending => { - unreachable!("Wallet::sign_psbt should not be pending in a sync context"); - }, - } - } -} - -/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can -/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, -/// which most wallets should be able to satisfy. Otherwise, consider implementing -/// [`WalletSourceSync`], which can provide a default implementation of this trait when used with -/// [`WalletSync`]. -/// -/// For an asynchronous version of this trait, see [`CoinSelectionSource`]. -// Note that updates to documentation on this trait should be copied to the asynchronous version. -pub trait CoinSelectionSourceSync { - /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are - /// available to spend. Implementations are free to pick their coin selection algorithm of - /// choice, as long as the following requirements are met: - /// - /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction - /// throughout coin selection, but must not be returned as part of the result. - /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction - /// throughout coin selection. In some cases, like when funding an anchor transaction, this - /// set is empty. Implementations should ensure they handle this correctly on their end, - /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be - /// provided, in which case a zero-value empty OP_RETURN output can be used instead. - /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the - /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. - /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this - /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC - /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for - /// anchor transactions, we will try your coin selection again with the same input-output - /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions - /// cannot be downsized. - /// - /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of - /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require - /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and - /// delaying block inclusion. - /// - /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they - /// can be re-used within new fee-bumped iterations of the original claiming transaction, - /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a - /// transaction associated with it, and all of the available UTXOs have already been assigned to - /// other claims, implementations must be willing to double spend their UTXOs. The choice of - /// which UTXOs to double spend is left to the implementation, but it must strive to keep the - /// set of other claims being double spent to a minimum. - /// - /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims - fn select_confirmed_utxos( - &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> Result; - - /// Signs and provides the full witness for all inputs within the transaction known to the - /// trait (i.e., any provided via [`CoinSelectionSourceSync::select_confirmed_utxos`]). - /// - /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the - /// unsigned transaction and then sign it with your wallet. - fn sign_psbt(&self, psbt: Psbt) -> Result; -} - -struct CoinSelectionSourceSyncWrapper(T) -where - T::Target: CoinSelectionSourceSync; - -// Implement `Deref` directly on CoinSelectionSourceSyncWrapper so that it can be used directly -// below, rather than via a wrapper. -impl Deref for CoinSelectionSourceSyncWrapper -where - T::Target: CoinSelectionSourceSync, -{ - type Target = Self; - fn deref(&self) -> &Self { - self - } -} - -impl CoinSelectionSource for CoinSelectionSourceSyncWrapper -where - T::Target: CoinSelectionSourceSync, -{ - fn select_confirmed_utxos<'a>( - &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], - target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, - ) -> impl Future> + MaybeSend + 'a { - let coins = self.0.select_confirmed_utxos( - claim_id, - must_spend, - must_pay_to, - target_feerate_sat_per_1000_weight, - max_tx_weight, - ); - async move { coins } - } - - fn sign_psbt<'a>( - &'a self, psbt: Psbt, - ) -> impl Future> + MaybeSend + 'a { - let psbt = self.0.sign_psbt(psbt); - async move { psbt } - } -} - /// A handler for [`Event::BumpTransaction`] events that sources confirmed UTXOs from a /// [`CoinSelectionSourceSync`] to fee bump transactions via Child-Pays-For-Parent (CPFP) or /// Replace-By-Fee (RBF). diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 9a3fdf47ade..09452605498 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -14,9 +14,7 @@ use crate::blinded_path::payment::DummyTlvs; use crate::chain::channelmonitor::ChannelMonitor; use crate::chain::transaction::OutPoint; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch}; -use crate::events::bump_transaction::sync::{ - BumpTransactionEventHandlerSync, WalletSourceSync, WalletSync, -}; +use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ ClaimedHTLC, ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PathFailure, @@ -54,6 +52,7 @@ use crate::util::test_channel_signer::SignerOp; use crate::util::test_channel_signer::TestChannelSigner; use crate::util::test_utils::{self, TestLogger}; use crate::util::test_utils::{TestChainMonitor, TestKeysInterface, TestScorer}; +use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; use bitcoin::amount::Amount; use bitcoin::block::{Block, Header, Version as BlockVersion}; diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index 8ad77046a4f..de49f86e51d 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -18,7 +18,6 @@ use bitcoin::{ use core::ops::Deref; -use crate::events::bump_transaction::sync::CoinSelectionSourceSync; use crate::ln::chan_utils::{ make_funding_redeemscript, BASE_INPUT_WEIGHT, EMPTY_SCRIPT_SIG_WEIGHT, FUNDING_TRANSACTION_WITNESS_WEIGHT, @@ -30,7 +29,9 @@ use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::async_poll::MaybeSend; -use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, Input, Utxo}; +use crate::util::wallet_utils::{ + CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, Utxo, +}; /// The components of a splice's funding transaction that are contributed by one party. #[derive(Debug, Clone)] diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 0af420a87e4..b83e6eb009d 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -13,7 +13,6 @@ use crate::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW; use crate::chain::channelmonitor::{ANTI_REORG_DELAY, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::chain::transaction::OutPoint; use crate::chain::ChannelMonitorUpdateStatus; -use crate::events::bump_transaction::sync::{WalletSourceSync, WalletSync}; use crate::events::{ClosureReason, Event, FundingInfo, HTLCHandlingFailureType}; use crate::ln::chan_utils; use crate::ln::channel::CHANNEL_ANNOUNCEMENT_PROPAGATION_DELAY; @@ -26,6 +25,7 @@ use crate::ln::types::ChannelId; use crate::routing::router::{PaymentParameters, RouteParameters}; use crate::util::errors::APIError; use crate::util::ser::Writeable; +use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; use crate::sync::Arc; diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 3d2eaf84bd7..9ee91ee730a 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -21,7 +21,6 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::OutPoint; use crate::chain::WatchedOutput; -use crate::events::bump_transaction::sync::WalletSourceSync; #[cfg(any(test, feature = "_externalize_tests"))] use crate::ln::chan_utils::CommitmentTransaction; use crate::ln::channel_state::ChannelDetails; @@ -61,7 +60,7 @@ use crate::util::persist::{KVStore, KVStoreSync, MonitorName}; use crate::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use crate::util::test_channel_signer::{EnforcementState, TestChannelSigner}; use crate::util::wakers::Notifier; -use crate::util::wallet_utils::{ConfirmedUtxo, Utxo}; +use crate::util::wallet_utils::{ConfirmedUtxo, Utxo, WalletSourceSync}; use bitcoin::amount::Amount; use bitcoin::block::Block; diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index 22e164a0edf..d70953f0e99 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -11,6 +11,8 @@ use core::future::Future; use core::ops::Deref; +use core::pin::pin; +use core::task; use crate::chain::chaininterface::fee_for_weight; use crate::chain::ClaimId; @@ -23,7 +25,7 @@ use crate::ln::funding::FundingTxInput; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::sync::Mutex; -use crate::util::async_poll::{MaybeSend, MaybeSync}; +use crate::util::async_poll::{dummy_waker, MaybeSend, MaybeSync}; use crate::util::hash_tables::{new_hash_map, HashMap}; use crate::util::logger::Logger; @@ -178,11 +180,9 @@ impl CoinSelection { /// which most wallets should be able to satisfy. Otherwise, consider implementing [`WalletSource`], /// which can provide a default implementation of this trait when used with [`Wallet`]. /// -/// For a synchronous version of this trait, see [`sync::CoinSelectionSourceSync`]. +/// For a synchronous version of this trait, see [`CoinSelectionSourceSync`]. /// /// This is not exported to bindings users as async is only supported in Rust. -/// -/// [`sync::CoinSelectionSourceSync`]: crate::events::bump_transaction::sync::CoinSelectionSourceSync // Note that updates to documentation on this trait should be copied to the synchronous version. pub trait CoinSelectionSource { /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are @@ -239,11 +239,9 @@ pub trait CoinSelectionSource { /// An alternative to [`CoinSelectionSource`] that can be implemented and used along [`Wallet`] to /// provide a default implementation to [`CoinSelectionSource`]. /// -/// For a synchronous version of this trait, see [`sync::WalletSourceSync`]. +/// For a synchronous version of this trait, see [`WalletSourceSync`]. /// /// This is not exported to bindings users as async is only supported in Rust. -/// -/// [`sync::WalletSourceSync`]: crate::events::bump_transaction::sync::WalletSourceSync // Note that updates to documentation on this trait should be copied to the synchronous version. pub trait WalletSource { /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. @@ -280,11 +278,9 @@ pub trait WalletSource { /// that would avoid conflicting double spends. If not enough UTXOs are available to do so, /// conflicting double spends may happen. /// -/// For a synchronous version of this wrapper, see [`sync::WalletSync`]. +/// For a synchronous version of this wrapper, see [`WalletSync`]. /// /// This is not exported to bindings users as async is only supported in Rust. -/// -/// [`sync::WalletSync`]: crate::events::bump_transaction::sync::WalletSync // Note that updates to documentation on this struct should be copied to the synchronous version. pub struct Wallet where @@ -547,3 +543,242 @@ where self.source.sign_psbt(psbt) } } + +/// An alternative to [`CoinSelectionSourceSync`] that can be implemented and used along +/// [`WalletSync`] to provide a default implementation to [`CoinSelectionSourceSync`]. +/// +/// For an asynchronous version of this trait, see [`WalletSource`]. +// Note that updates to documentation on this trait should be copied to the asynchronous version. +pub trait WalletSourceSync { + /// Returns all UTXOs, with at least 1 confirmation each, that are available to spend. + fn list_confirmed_utxos(&self) -> Result, ()>; + + /// Returns the previous transaction containing the UTXO referenced by the outpoint. + fn get_prevtx(&self, outpoint: OutPoint) -> Result; + + /// Returns a script to use for change above dust resulting from a successful coin selection + /// attempt. + fn get_change_script(&self) -> Result; + + /// Signs and provides the full [`TxIn::script_sig`] and [`TxIn::witness`] for all inputs within + /// the transaction known to the wallet (i.e., any provided via + /// [`WalletSource::list_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + /// + /// [`TxIn::script_sig`]: bitcoin::TxIn::script_sig + /// [`TxIn::witness`]: bitcoin::TxIn::witness + fn sign_psbt(&self, psbt: Psbt) -> Result; +} + +struct WalletSourceSyncWrapper(T) +where + T::Target: WalletSourceSync; + +// Implement `Deref` directly on WalletSourceSyncWrapper so that it can be used directly +// below, rather than via a wrapper. +impl Deref for WalletSourceSyncWrapper +where + T::Target: WalletSourceSync, +{ + type Target = Self; + fn deref(&self) -> &Self { + self + } +} + +impl WalletSource for WalletSourceSyncWrapper +where + T::Target: WalletSourceSync, +{ + fn list_confirmed_utxos<'a>( + &'a self, + ) -> impl Future, ()>> + MaybeSend + 'a { + let utxos = self.0.list_confirmed_utxos(); + async move { utxos } + } + + fn get_prevtx<'a>( + &'a self, outpoint: OutPoint, + ) -> impl Future> + MaybeSend + 'a { + let prevtx = self.0.get_prevtx(outpoint); + Box::pin(async move { prevtx }) + } + + fn get_change_script<'a>( + &'a self, + ) -> impl Future> + MaybeSend + 'a { + let script = self.0.get_change_script(); + async move { script } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + let signed_psbt = self.0.sign_psbt(psbt); + async move { signed_psbt } + } +} + +/// A wrapper over [`WalletSourceSync`] that implements [`CoinSelectionSourceSync`] by preferring +/// UTXOs that would avoid conflicting double spends. If not enough UTXOs are available to do so, +/// conflicting double spends may happen. +/// +/// For an asynchronous version of this wrapper, see [`Wallet`]. +// Note that updates to documentation on this struct should be copied to the asynchronous version. +pub struct WalletSync +where + W::Target: WalletSourceSync + MaybeSend, +{ + wallet: Wallet, L>, +} + +impl WalletSync +where + W::Target: WalletSourceSync + MaybeSend, +{ + /// Constructs a new [`WalletSync`] instance. + pub fn new(source: W, logger: L) -> Self { + Self { wallet: Wallet::new(WalletSourceSyncWrapper(source), logger) } + } +} + +impl CoinSelectionSourceSync + for WalletSync +where + W::Target: WalletSourceSync + MaybeSend + MaybeSync, +{ + fn select_confirmed_utxos( + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> Result { + let fut = self.wallet.select_confirmed_utxos( + claim_id, + must_spend, + must_pay_to, + target_feerate_sat_per_1000_weight, + max_tx_weight, + ); + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match pin!(fut).poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + unreachable!( + "Wallet::select_confirmed_utxos should not be pending in a sync context" + ); + }, + } + } + + fn sign_psbt(&self, psbt: Psbt) -> Result { + let fut = self.wallet.sign_psbt(psbt); + let mut waker = dummy_waker(); + let mut ctx = task::Context::from_waker(&mut waker); + match pin!(fut).poll(&mut ctx) { + task::Poll::Ready(result) => result, + task::Poll::Pending => { + unreachable!("Wallet::sign_psbt should not be pending in a sync context"); + }, + } + } +} + +/// An abstraction over a bitcoin wallet that can perform coin selection over a set of UTXOs and can +/// sign for them. The coin selection method aims to mimic Bitcoin Core's `fundrawtransaction` RPC, +/// which most wallets should be able to satisfy. Otherwise, consider implementing +/// [`WalletSourceSync`], which can provide a default implementation of this trait when used with +/// [`WalletSync`]. +/// +/// For an asynchronous version of this trait, see [`CoinSelectionSource`]. +// Note that updates to documentation on this trait should be copied to the asynchronous version. +pub trait CoinSelectionSourceSync { + /// Performs coin selection of a set of UTXOs, with at least 1 confirmation each, that are + /// available to spend. Implementations are free to pick their coin selection algorithm of + /// choice, as long as the following requirements are met: + /// + /// 1. `must_spend` contains a set of [`Input`]s that must be included in the transaction + /// throughout coin selection, but must not be returned as part of the result. + /// 2. `must_pay_to` contains a set of [`TxOut`]s that must be included in the transaction + /// throughout coin selection. In some cases, like when funding an anchor transaction, this + /// set is empty. Implementations should ensure they handle this correctly on their end, + /// e.g., Bitcoin Core's `fundrawtransaction` RPC requires at least one output to be + /// provided, in which case a zero-value empty OP_RETURN output can be used instead. + /// 3. Enough inputs must be selected/contributed for the resulting transaction (including the + /// inputs and outputs noted above) to meet `target_feerate_sat_per_1000_weight`. + /// 4. The final transaction must have a weight smaller than `max_tx_weight`; if this + /// constraint can't be met, return an `Err`. In the case of counterparty-signed HTLC + /// transactions, we will remove a chunk of HTLCs and try your algorithm again. As for + /// anchor transactions, we will try your coin selection again with the same input-output + /// set when you call [`ChannelMonitor::rebroadcast_pending_claims`], as anchor transactions + /// cannot be downsized. + /// + /// Implementations must take note that [`Input::satisfaction_weight`] only tracks the weight of + /// the input's `script_sig` and `witness`. Some wallets, like Bitcoin Core's, may require + /// providing the full input weight. Failing to do so may lead to underestimating fee bumps and + /// delaying block inclusion. + /// + /// The `claim_id` must map to the set of external UTXOs assigned to the claim, such that they + /// can be re-used within new fee-bumped iterations of the original claiming transaction, + /// ensuring that claims don't double spend each other. If a specific `claim_id` has never had a + /// transaction associated with it, and all of the available UTXOs have already been assigned to + /// other claims, implementations must be willing to double spend their UTXOs. The choice of + /// which UTXOs to double spend is left to the implementation, but it must strive to keep the + /// set of other claims being double spent to a minimum. + /// + /// [`ChannelMonitor::rebroadcast_pending_claims`]: crate::chain::channelmonitor::ChannelMonitor::rebroadcast_pending_claims + fn select_confirmed_utxos( + &self, claim_id: Option, must_spend: Vec, must_pay_to: &[TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> Result; + + /// Signs and provides the full witness for all inputs within the transaction known to the + /// trait (i.e., any provided via [`CoinSelectionSourceSync::select_confirmed_utxos`]). + /// + /// If your wallet does not support signing PSBTs you can call `psbt.extract_tx()` to get the + /// unsigned transaction and then sign it with your wallet. + fn sign_psbt(&self, psbt: Psbt) -> Result; +} + +pub(crate) struct CoinSelectionSourceSyncWrapper(pub(crate) T) +where + T::Target: CoinSelectionSourceSync; + +// Implement `Deref` directly on CoinSelectionSourceSyncWrapper so that it can be used directly +// below, rather than via a wrapper. +impl Deref for CoinSelectionSourceSyncWrapper +where + T::Target: CoinSelectionSourceSync, +{ + type Target = Self; + fn deref(&self) -> &Self { + self + } +} + +impl CoinSelectionSource for CoinSelectionSourceSyncWrapper +where + T::Target: CoinSelectionSourceSync, +{ + fn select_confirmed_utxos<'a>( + &'a self, claim_id: Option, must_spend: Vec, must_pay_to: &'a [TxOut], + target_feerate_sat_per_1000_weight: u32, max_tx_weight: u64, + ) -> impl Future> + MaybeSend + 'a { + let coins = self.0.select_confirmed_utxos( + claim_id, + must_spend, + must_pay_to, + target_feerate_sat_per_1000_weight, + max_tx_weight, + ); + async move { coins } + } + + fn sign_psbt<'a>( + &'a self, psbt: Psbt, + ) -> impl Future> + MaybeSend + 'a { + let psbt = self.0.sign_psbt(psbt); + async move { psbt } + } +} From ae52168e8d366df1e065a7f9ed37468348092b9d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 28 Jan 2026 17:37:40 -0600 Subject: [PATCH 19/21] Make ConfirmedUtxo the primary type FundingTxInput was originally designed for channel funding but is now used more broadly for coin selection and splicing. The name ConfirmedUtxo better reflects its general-purpose nature as a confirmed UTXO with previous transaction data. Make ConfirmedUtxo the real struct in wallet_utils and alias FundingTxInput to it for backward compatibility. Co-Authored-By: Claude Sonnet 4.5 --- lightning/src/ln/funding.rs | 175 +--------------------------- lightning/src/util/wallet_utils.rs | 176 ++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 175 deletions(-) diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index de49f86e51d..a15b78c8b78 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -11,10 +11,7 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::PublicKey; -use bitcoin::{ - Amount, FeeRate, OutPoint, Script, ScriptBuf, Sequence, SignedAmount, Transaction, TxOut, - WScriptHash, Weight, -}; +use bitcoin::{Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, TxOut, WScriptHash, Weight}; use core::ops::Deref; @@ -27,10 +24,9 @@ use crate::ln::msgs; use crate::ln::types::ChannelId; use crate::ln::LN_MAX_MSG_LEN; use crate::prelude::*; -use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::util::async_poll::MaybeSend; use crate::util::wallet_utils::{ - CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, Utxo, + CoinSelection, CoinSelectionSource, CoinSelectionSourceSync, Input, }; /// The components of a splice's funding transaction that are contributed by one party. @@ -412,172 +408,7 @@ impl FundingContribution { /// An input to contribute to a channel's funding transaction either when using the v2 channel /// establishment protocol or when splicing. -#[derive(Debug, Clone)] -pub struct FundingTxInput { - /// The unspent [`TxOut`] found in [`prevtx`]. - /// - /// [`TxOut`]: bitcoin::TxOut - /// [`prevtx`]: Self::prevtx - pub(crate) utxo: Utxo, - - /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. - /// - /// [`TxOut`]: bitcoin::TxOut - /// [`utxo`]: Self::utxo - pub(crate) prevtx: Transaction, -} - -impl_writeable_tlv_based!(FundingTxInput, { - (1, utxo, required), - (3, _sequence, (legacy, Sequence, - |read_val: Option<&Sequence>| { - if let Some(sequence) = read_val { - // Utxo contains sequence now, so update it if the value read here differs since - // this indicates Utxo::sequence was read with default_value - let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required"); - if utxo.sequence != *sequence { - utxo.sequence = *sequence; - } - } - Ok(()) - }, - |input: &FundingTxInput| Some(input.utxo.sequence))), - (5, prevtx, required), -}); - -impl FundingTxInput { - fn new bool>( - prevtx: Transaction, vout: u32, witness_weight: Weight, script_filter: F, - ) -> Result { - Ok(FundingTxInput { - utxo: Utxo { - outpoint: bitcoin::OutPoint { txid: prevtx.compute_txid(), vout }, - output: prevtx - .output - .get(vout as usize) - .filter(|output| script_filter(&output.script_pubkey)) - .ok_or(())? - .clone(), - satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - }, - prevtx, - }) - } - - /// Creates an input spending a P2WPKH output from the given `prevtx` at index `vout`. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2wpkh(prevtx: Transaction, vout: u32) -> Result { - let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT) - - if cfg!(feature = "grind_signatures") { - // Guarantees a low R signature - Weight::from_wu(1) - } else { - Weight::ZERO - }; - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2wpkh) - } - - /// Creates an input spending a P2WSH output from the given `prevtx` at index `vout`. - /// - /// Requires passing the weight of witness needed to satisfy the output's script. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2wsh(prevtx: Transaction, vout: u32, witness_weight: Weight) -> Result { - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2wsh) - } - - /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. - /// - /// This is meant for inputs spending a taproot output using the key path. See - /// [`new_p2tr_script_spend`] for when spending using a script path. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`new_p2tr_script_spend`]: Self::new_p2tr_script_spend - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2tr_key_spend(prevtx: Transaction, vout: u32) -> Result { - let witness_weight = Weight::from_wu(P2TR_KEY_PATH_WITNESS_WEIGHT); - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2tr) - } - - /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. - /// - /// Requires passing the weight of witness needed to satisfy a script path of the taproot - /// output. See [`new_p2tr_key_spend`] for when spending using the key path. - /// - /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden - /// by [`set_sequence`]. - /// - /// Returns `Err` if no such output exists in `prevtx` at index `vout`. - /// - /// [`new_p2tr_key_spend`]: Self::new_p2tr_key_spend - /// - /// [`TxIn::sequence`]: bitcoin::TxIn::sequence - /// [`set_sequence`]: Self::set_sequence - pub fn new_p2tr_script_spend( - prevtx: Transaction, vout: u32, witness_weight: Weight, - ) -> Result { - FundingTxInput::new(prevtx, vout, witness_weight, Script::is_p2tr) - } - - #[cfg(test)] - pub(crate) fn new_p2pkh(prevtx: Transaction, vout: u32) -> Result { - FundingTxInput::new(prevtx, vout, Weight::ZERO, Script::is_p2pkh) - } - - /// The outpoint of the UTXO being spent. - pub fn outpoint(&self) -> bitcoin::OutPoint { - self.utxo.outpoint - } - - /// The unspent output. - pub fn output(&self) -> &TxOut { - &self.utxo.output - } - - /// The sequence number to use in the [`TxIn`]. - /// - /// [`TxIn`]: bitcoin::TxIn - pub fn sequence(&self) -> Sequence { - self.utxo.sequence - } - - /// Sets the sequence number to use in the [`TxIn`]. - /// - /// [`TxIn`]: bitcoin::TxIn - pub fn set_sequence(&mut self, sequence: Sequence) { - self.utxo.sequence = sequence; - } - - /// Converts the [`FundingTxInput`] into a [`Utxo`]. - pub fn into_utxo(self) -> Utxo { - self.utxo - } - - /// Converts the [`FundingTxInput`] into a [`TxOut`]. - pub fn into_output(self) -> TxOut { - self.utxo.output - } -} +pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo; #[cfg(test)] mod tests { diff --git a/lightning/src/util/wallet_utils.rs b/lightning/src/util/wallet_utils.rs index d70953f0e99..d2ce5614e53 100644 --- a/lightning/src/util/wallet_utils.rs +++ b/lightning/src/util/wallet_utils.rs @@ -21,7 +21,6 @@ use crate::ln::chan_utils::{ BASE_INPUT_WEIGHT, BASE_TX_SIZE, EMPTY_SCRIPT_SIG_WEIGHT, P2WSH_TXOUT_WEIGHT, SEGWIT_MARKER_FLAG_WEIGHT, }; -use crate::ln::funding::FundingTxInput; use crate::prelude::*; use crate::sign::{P2TR_KEY_PATH_WITNESS_WEIGHT, P2WPKH_WITNESS_WEIGHT}; use crate::sync::Mutex; @@ -33,7 +32,10 @@ use bitcoin::amount::Amount; use bitcoin::consensus::Encodable; use bitcoin::constants::WITNESS_SCALE_FACTOR; use bitcoin::key::TweakedPublicKey; -use bitcoin::{OutPoint, Psbt, PubkeyHash, ScriptBuf, Sequence, Transaction, TxOut, WPubkeyHash}; +use bitcoin::{ + OutPoint, Psbt, PubkeyHash, Script, ScriptBuf, Sequence, Transaction, TxOut, WPubkeyHash, + Weight, +}; /// An input that must be included in a transaction when performing coin selection through /// [`CoinSelectionSource::select_confirmed_utxos`]. It is guaranteed to be a SegWit input, so it @@ -149,7 +151,175 @@ impl Utxo { } /// An unspent transaction output with at least one confirmation. -pub type ConfirmedUtxo = FundingTxInput; +/// +/// Can be used as an input to contribute to a channel's funding transaction either when using the +/// v2 channel establishment protocol or when splicing. +#[derive(Debug, Clone)] +pub struct ConfirmedUtxo { + /// The unspent [`TxOut`] found in [`prevtx`]. + /// + /// [`TxOut`]: bitcoin::TxOut + /// [`prevtx`]: Self::prevtx + pub(crate) utxo: Utxo, + + /// The transaction containing the unspent [`TxOut`] referenced by [`utxo`]. + /// + /// [`TxOut`]: bitcoin::TxOut + /// [`utxo`]: Self::utxo + pub(crate) prevtx: Transaction, +} + +impl_writeable_tlv_based!(ConfirmedUtxo, { + (1, utxo, required), + (3, _sequence, (legacy, Sequence, + |read_val: Option<&Sequence>| { + if let Some(sequence) = read_val { + // Utxo contains sequence now, so update it if the value read here differs since + // this indicates Utxo::sequence was read with default_value + let utxo: &mut Utxo = utxo.0.as_mut().expect("utxo is required"); + if utxo.sequence != *sequence { + utxo.sequence = *sequence; + } + } + Ok(()) + }, + |utxo: &ConfirmedUtxo| Some(utxo.utxo.sequence))), + (5, prevtx, required), +}); + +impl ConfirmedUtxo { + fn new bool>( + prevtx: Transaction, vout: u32, witness_weight: Weight, script_filter: F, + ) -> Result { + Ok(ConfirmedUtxo { + utxo: Utxo { + outpoint: bitcoin::OutPoint { txid: prevtx.compute_txid(), vout }, + output: prevtx + .output + .get(vout as usize) + .filter(|output| script_filter(&output.script_pubkey)) + .ok_or(())? + .clone(), + satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT + witness_weight.to_wu(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + }, + prevtx, + }) + } + + /// Creates an input spending a P2WPKH output from the given `prevtx` at index `vout`. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2wpkh(prevtx: Transaction, vout: u32) -> Result { + let witness_weight = Weight::from_wu(P2WPKH_WITNESS_WEIGHT) + - if cfg!(feature = "grind_signatures") { + // Guarantees a low R signature + Weight::from_wu(1) + } else { + Weight::ZERO + }; + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2wpkh) + } + + /// Creates an input spending a P2WSH output from the given `prevtx` at index `vout`. + /// + /// Requires passing the weight of witness needed to satisfy the output's script. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2wsh(prevtx: Transaction, vout: u32, witness_weight: Weight) -> Result { + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2wsh) + } + + /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. + /// + /// This is meant for inputs spending a taproot output using the key path. See + /// [`new_p2tr_script_spend`] for when spending using a script path. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`new_p2tr_script_spend`]: Self::new_p2tr_script_spend + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2tr_key_spend(prevtx: Transaction, vout: u32) -> Result { + let witness_weight = Weight::from_wu(P2TR_KEY_PATH_WITNESS_WEIGHT); + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2tr) + } + + /// Creates an input spending a P2TR output from the given `prevtx` at index `vout`. + /// + /// Requires passing the weight of witness needed to satisfy a script path of the taproot + /// output. See [`new_p2tr_key_spend`] for when spending using the key path. + /// + /// Uses [`Sequence::ENABLE_RBF_NO_LOCKTIME`] as the [`TxIn::sequence`], which can be overridden + /// by [`set_sequence`]. + /// + /// Returns `Err` if no such output exists in `prevtx` at index `vout`. + /// + /// [`new_p2tr_key_spend`]: Self::new_p2tr_key_spend + /// + /// [`TxIn::sequence`]: bitcoin::TxIn::sequence + /// [`set_sequence`]: Self::set_sequence + pub fn new_p2tr_script_spend( + prevtx: Transaction, vout: u32, witness_weight: Weight, + ) -> Result { + ConfirmedUtxo::new(prevtx, vout, witness_weight, Script::is_p2tr) + } + + #[cfg(test)] + pub(crate) fn new_p2pkh(prevtx: Transaction, vout: u32) -> Result { + ConfirmedUtxo::new(prevtx, vout, Weight::ZERO, Script::is_p2pkh) + } + + /// The outpoint of the UTXO being spent. + pub fn outpoint(&self) -> bitcoin::OutPoint { + self.utxo.outpoint + } + + /// The unspent output. + pub fn output(&self) -> &TxOut { + &self.utxo.output + } + + /// The sequence number to use in the [`TxIn`]. + /// + /// [`TxIn`]: bitcoin::TxIn + pub fn sequence(&self) -> Sequence { + self.utxo.sequence + } + + /// Sets the sequence number to use in the [`TxIn`]. + /// + /// [`TxIn`]: bitcoin::TxIn + pub fn set_sequence(&mut self, sequence: Sequence) { + self.utxo.sequence = sequence; + } + + /// Converts the [`ConfirmedUtxo`] into a [`Utxo`]. + pub fn into_utxo(self) -> Utxo { + self.utxo + } + + /// Converts the [`ConfirmedUtxo`] into a [`TxOut`]. + pub fn into_output(self) -> TxOut { + self.utxo.output + } +} /// The result of a successful coin selection attempt for a transaction requiring additional UTXOs /// to cover its fees. From 0c5cc5e6bc211ee396a31d2140d8a712d82e0f92 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 6 Feb 2026 15:04:12 -0600 Subject: [PATCH 20/21] Add expect_splice_failed_events helper Add a helper function to assert that SpliceFailed events contain the expected channel_id and contributed inputs/outputs. This ensures that tests verify the contributions match what was originally provided. Co-Authored-By: Claude Opus 4.5 --- lightning/src/ln/functional_test_utils.rs | 21 ++++++++++++- lightning/src/ln/splicing_tests.rs | 38 +++++++++-------------- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 09452605498..262d0e37826 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -27,7 +27,7 @@ use crate::ln::channelmanager::{ AChannelManager, ChainParameters, ChannelManager, ChannelManagerReadArgs, PaymentId, RAACommitmentOrder, MIN_CLTV_EXPIRY_DELTA, }; -use crate::ln::funding::FundingTxInput; +use crate::ln::funding::{FundingContribution, FundingTxInput}; use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, @@ -3201,6 +3201,25 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( } } +#[cfg(any(test, ldk_bench, feature = "_test_utils"))] +pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, + funding_contribution: FundingContribution, +) { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::SpliceFailed { channel_id, contributed_inputs, contributed_outputs, .. } => { + assert_eq!(*expected_channel_id, *channel_id); + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*contributed_inputs, expected_inputs); + assert_eq!(*contributed_outputs, expected_outputs); + }, + _ => panic!("Unexpected event"), + } +} + pub fn expect_probe_successful_events( node: &Node, mut probe_results: Vec<(PaymentHash, PaymentId)>, ) { diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index b83e6eb009d..bffa08184b9 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -520,7 +520,8 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); + let funding_contribution = + initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); // Attempt a splice negotiation that only goes up to receiving `splice_init`. Reconnecting // should implicitly abort the negotiation and reset the splice state such that we're able to @@ -558,14 +559,15 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); + let funding_contribution = + initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); // Attempt a splice negotiation that ends mid-construction of the funding transaction. // Reconnecting should implicitly abort the negotiation and reset the splice state such that @@ -608,14 +610,15 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { nodes[1].node.peer_disconnected(node_id_0); } - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); reconnect_args.send_announcement_sigs = (true, true); reconnect_nodes(reconnect_args); - let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); + let funding_contribution = + initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); // Attempt a splice negotiation that ends before the initial `commitment_signed` messages are // exchanged. The node missing the other's `commitment_signed` upon reconnecting should @@ -689,7 +692,7 @@ fn do_test_splice_state_reset_on_disconnect(reload: bool) { let tx_abort = get_event_msg!(nodes[0], MessageSendEvent::SendTxAbort, node_id_1); nodes[1].node.handle_tx_abort(node_id_0, &tx_abort); - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); // Attempt a splice negotiation that completes, (i.e. `tx_signatures` are exchanged). Reconnecting // should not abort the negotiation or reset the splice state. @@ -749,7 +752,8 @@ fn test_config_reject_inbound_splices() { value: Amount::from_sat(1_000), script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(), }]); - let _ = initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); + let funding_contribution = + initiate_splice(&nodes[0], &nodes[1], channel_id, contribution.clone()); let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); nodes[1].node.handle_stfu(node_id_0, &stfu); @@ -770,7 +774,7 @@ fn test_config_reject_inbound_splices() { nodes[0].node.peer_disconnected(node_id_1); nodes[1].node.peer_disconnected(node_id_0); - let _event = get_event!(nodes[0], Event::SpliceFailed); + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]); reconnect_args.send_channel_ready = (true, true); @@ -1959,14 +1963,7 @@ fn fail_splice_on_interactive_tx_error() { get_event_msg!(acceptor, MessageSendEvent::SendTxComplete, node_id_initiator); initiator.node.handle_tx_add_input(node_id_acceptor, &tx_add_input); - let event = get_event!(initiator, Event::SpliceFailed); - match event { - Event::SpliceFailed { contributed_inputs, .. } => { - assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); - }, - _ => panic!("Expected Event::SpliceFailed"), - } + expect_splice_failed_events(initiator, &channel_id, funding_contribution); let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); @@ -2015,14 +2012,7 @@ fn fail_splice_on_tx_abort() { let tx_abort = get_event_msg!(acceptor, MessageSendEvent::SendTxAbort, node_id_initiator); initiator.node.handle_tx_abort(node_id_acceptor, &tx_abort); - let event = get_event!(initiator, Event::SpliceFailed); - match event { - Event::SpliceFailed { contributed_inputs, .. } => { - assert_eq!(contributed_inputs.len(), 1); - assert_eq!(contributed_inputs[0], funding_contribution.into_tx_parts().0[0].outpoint()); - }, - _ => panic!("Expected Event::SpliceFailed"), - } + expect_splice_failed_events(initiator, &channel_id, funding_contribution); let tx_abort = get_event_msg!(initiator, MessageSendEvent::SendTxAbort, node_id_acceptor); acceptor.node.handle_tx_abort(node_id_initiator, &tx_abort); From 4ee3ef17f5a20ec136971d24f60f7e9cfe67bdf7 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Thu, 5 Feb 2026 14:06:07 -0600 Subject: [PATCH 21/21] Split DiscardFunding from SpliceFailed event When a splice fails, users need to reclaim UTXOs they contributed to the funding transaction. Previously, the contributed inputs and outputs were included in the SpliceFailed event. This commit splits them into a separate DiscardFunding event with a new FundingInfo::Contribution variant, providing a consistent interface for UTXO cleanup across all funding failure scenarios. Changes: - Add FundingInfo::Contribution variant to hold inputs/outputs for DiscardFunding events - Remove contributed_inputs/outputs fields from SpliceFailed event - Add QuiescentError enum for better error handling in funding_contributed - Emit DiscardFunding on all funding_contributed error paths - Filter duplicate inputs/outputs when contribution overlaps existing pending contribution - Return Err(APIError) from funding_contributed on all error cases - Add comprehensive test coverage for funding_contributed error paths Co-Authored-By: Claude Opus 4.5 --- lightning/src/events/mod.rs | 23 +- lightning/src/ln/channel.rs | 86 +++-- lightning/src/ln/channelmanager.rs | 177 ++++++++-- lightning/src/ln/functional_test_utils.rs | 46 ++- lightning/src/ln/funding.rs | 8 + lightning/src/ln/splicing_tests.rs | 405 +++++++++++++++++++++- 6 files changed, 667 insertions(+), 78 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 02ad5a49483..89f48f266be 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -78,6 +78,13 @@ pub enum FundingInfo { /// The outpoint of the funding outpoint: transaction::OutPoint, }, + /// The contributions used to for a dual funding or splice funding transaction. + Contribution { + /// UTXOs spent as inputs contributed to the funding transaction. + inputs: Vec, + /// Outputs contributed to the funding transaction. + outputs: Vec, + }, } impl_writeable_tlv_based_enum!(FundingInfo, @@ -86,6 +93,10 @@ impl_writeable_tlv_based_enum!(FundingInfo, }, (1, OutPoint) => { (1, outpoint, required) + }, + (2, Contribution) => { + (0, inputs, optional_vec), + (1, outputs, optional_vec), } ); @@ -1580,10 +1591,6 @@ pub enum Event { abandoned_funding_txo: Option, /// The features that this channel will operate with, if available. channel_type: Option, - /// UTXOs spent as inputs contributed to the splice transaction. - contributed_inputs: Vec, - /// Outputs contributed to the splice transaction. - contributed_outputs: Vec, }, /// Used to indicate to the user that they can abandon the funding transaction and recycle the /// inputs for another purpose. @@ -2378,8 +2385,6 @@ impl Writeable for Event { ref counterparty_node_id, ref abandoned_funding_txo, ref channel_type, - ref contributed_inputs, - ref contributed_outputs, } => { 52u8.write(writer)?; write_tlv_fields!(writer, { @@ -2388,8 +2393,6 @@ impl Writeable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), - (11, *contributed_inputs, optional_vec), - (13, *contributed_outputs, optional_vec), }); }, &Event::FundingNeeded { .. } => { @@ -3015,8 +3018,6 @@ impl MaybeReadable for Event { (5, user_channel_id, required), (7, counterparty_node_id, required), (9, abandoned_funding_txo, option), - (11, contributed_inputs, optional_vec), - (13, contributed_outputs, optional_vec), }); Ok(Some(Event::SpliceFailed { @@ -3025,8 +3026,6 @@ impl MaybeReadable for Event { counterparty_node_id: counterparty_node_id.0.unwrap(), abandoned_funding_txo, channel_type, - contributed_inputs: contributed_inputs.unwrap_or_default(), - contributed_outputs: contributed_outputs.unwrap_or_default(), })) }; f() diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index a079e07c8f3..62e4c6800c2 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2940,6 +2940,35 @@ pub(crate) enum QuiescentAction { DoNothing, } +pub(super) enum QuiescentError { + DoNothing, + DiscardFunding { inputs: Vec, outputs: Vec }, + FailSplice(SpliceFundingFailed), +} + +impl From for QuiescentError { + fn from(action: QuiescentAction) -> Self { + match action { + QuiescentAction::LegacySplice(_) => { + debug_assert!(false); + QuiescentError::DoNothing + }, + QuiescentAction::Splice { contribution, .. } => { + let (contributed_inputs, contributed_outputs) = + contribution.into_contributed_inputs_and_outputs(); + return QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo: None, + channel_type: None, + contributed_inputs, + contributed_outputs, + }); + }, + #[cfg(any(test, fuzzing))] + QuiescentAction::DoNothing => QuiescentError::DoNothing, + } + } +} + pub(crate) enum StfuResponse { Stfu(msgs::Stfu), SpliceInit(msgs::SpliceInit), @@ -11907,9 +11936,30 @@ where pub fn funding_contributed( &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L, - ) -> Result, SpliceFundingFailed> { + ) -> Result, QuiescentError> { debug_assert!(contribution.is_splice()); + if let Some(QuiescentAction::Splice { contribution: existing, .. }) = &self.quiescent_action + { + let (new_inputs, new_outputs) = contribution.into_contributed_inputs_and_outputs(); + + // Filter out inputs/outputs already in the existing contribution + let inputs: Vec<_> = new_inputs + .into_iter() + .filter(|input| !existing.contributed_inputs().any(|e| e == *input)) + .collect(); + let outputs: Vec<_> = new_outputs + .into_iter() + .filter(|output| !existing.contributed_outputs().any(|e| *e == *output)) + .collect(); + + if inputs.is_empty() && outputs.is_empty() { + return Err(QuiescentError::DoNothing); + } + + return Err(QuiescentError::DiscardFunding { inputs, outputs }); + } + if let Err(e) = contribution.net_value().and_then(|our_funding_contribution| { // For splice-out, our_funding_contribution is adjusted to cover fees if there // aren't any inputs. @@ -11920,37 +11970,15 @@ where let (contributed_inputs, contributed_outputs) = contribution.into_contributed_inputs_and_outputs(); - return Err(SpliceFundingFailed { + return Err(QuiescentError::FailSplice(SpliceFundingFailed { funding_txo: None, channel_type: None, contributed_inputs, contributed_outputs, - }); + })); } - self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }).map_err( - |action| { - // FIXME: Any better way to do this? - if let QuiescentAction::Splice { contribution, .. } = action { - let (contributed_inputs, contributed_outputs) = - contribution.into_contributed_inputs_and_outputs(); - SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs, - contributed_outputs, - } - } else { - debug_assert!(false); - SpliceFundingFailed { - funding_txo: None, - channel_type: None, - contributed_inputs: vec![], - contributed_outputs: vec![], - } - } - }, - ) + self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime }) } fn send_splice_init(&mut self, instructions: SpliceInstructions) -> msgs::SpliceInit { @@ -13069,19 +13097,19 @@ where #[rustfmt::skip] pub fn propose_quiescence( &mut self, logger: &L, action: QuiescentAction, - ) -> Result, QuiescentAction> { + ) -> Result, QuiescentError> { log_debug!(logger, "Attempting to initiate quiescence"); if !self.context.is_usable() { log_debug!(logger, "Channel is not in a usable state to propose quiescence"); - return Err(action); + return Err(action.into()); } if self.quiescent_action.is_some() { log_debug!( logger, "Channel already has a pending quiescent action and cannot start another", ); - return Err(action); + return Err(action.into()); } self.quiescent_action = Some(action); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index be290f407de..8887074c522 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -56,6 +56,7 @@ use crate::events::{FundingInfo, PaidBolt12Invoice}; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; #[cfg(any(test, fuzzing))] use crate::ln::channel::QuiescentAction; +use crate::ln::channel::QuiescentError; use crate::ln::channel::{ self, hold_time_since, Channel, ChannelError, ChannelUpdateStatus, DisconnectResult, FundedChannel, FundingTxSigned, InboundV1Channel, OutboundV1Channel, PendingV2Channel, @@ -4152,8 +4153,16 @@ impl< user_channel_id: shutdown_res.user_channel_id, abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: shutdown_res.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -4726,8 +4735,16 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -6395,6 +6412,15 @@ impl< let per_peer_state = self.per_peer_state.read().unwrap(); let peer_state_mutex_opt = per_peer_state.get(counterparty_node_id); if peer_state_mutex_opt.is_none() { + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); result = Err(APIError::ChannelUnavailable { err: format!("Can't find a peer matching the passed counterparty node_id {counterparty_node_id}") }); @@ -6421,28 +6447,78 @@ impl< ); } }, - Err(splice_funding_failed) => { + Err(QuiescentError::DoNothing) => { + result = Err(APIError::APIMisuseError { + err: format!( + "Duplicate funding contribution for channel {}", + channel_id + ), + }); + }, + Err(QuiescentError::DiscardFunding { inputs, outputs }) => { + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); + result = Err(APIError::APIMisuseError { + err: format!( + "Channel {} already has a pending funding contribution", + channel_id + ), + }); + }, + Err(QuiescentError::FailSplice(SpliceFundingFailed { + funding_txo, + channel_type, + contributed_inputs, + contributed_outputs, + })) => { let pending_events = &mut self.pending_events.lock().unwrap(); pending_events.push_back(( events::Event::SpliceFailed { channel_id: *channel_id, counterparty_node_id: *counterparty_node_id, user_channel_id: channel.context().get_user_id(), - abandoned_funding_txo: splice_funding_failed.funding_txo, - channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed - .contributed_inputs, - contributed_outputs: splice_funding_failed - .contributed_outputs, + abandoned_funding_txo: funding_txo, + channel_type, }, None, )); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { + inputs: contributed_inputs, + outputs: contributed_outputs, + }, + }, + None, + )); + result = Err(APIError::APIMisuseError { + err: format!( + "Channel {} cannot accept funding contribution", + channel_id + ), + }); }, } return NotifyOption::DoPersist; }, None => { + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); result = Err(APIError::APIMisuseError { err: format!( "Channel with id {} not expecting funding contribution", @@ -6453,6 +6529,15 @@ impl< }, }, None => { + let (inputs, outputs) = contribution.into_contributed_inputs_and_outputs(); + let pending_events = &mut self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: *channel_id, + funding_info: FundingInfo::Contribution { inputs, outputs }, + }, + None, + )); result = Err(APIError::ChannelUnavailable { err: format!( "Channel with id {} not found for the passed counterparty node_id {}", @@ -11309,9 +11394,17 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: channel.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, }, None)); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, + }, + None, + )); } Err(MsgHandleErrInternal::from_chan_no_close(error, channel_id)) }, @@ -11452,9 +11545,17 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type.clone(), - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, }, None)); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, + }, + None, + )); } Err(MsgHandleErrInternal::from_chan_no_close(error, msg.channel_id)) }, @@ -11586,9 +11687,17 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ user_channel_id: chan_entry.get().context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, }, None)); + pending_events.push_back(( + events::Event::DiscardFunding { + channel_id: msg.channel_id, + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, + }, + None, + )); } Ok(persist) @@ -13276,7 +13385,10 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }); notify = NotifyOption::SkipPersistHandleEvents; }, - Err(action) => log_trace!(logger, "Failed to propose quiescence for: {:?}", action), + Err(e) => { + debug_assert!(matches!(e, QuiescentError::DoNothing)); + log_trace!(logger, "Failed to propose quiescence"); + }, } } else { result = Err(APIError::APIMisuseError { @@ -14561,8 +14673,13 @@ impl< user_channel_id: chan.context().get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }); + splice_failed_events.push(events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }); } @@ -17165,9 +17282,9 @@ impl< let our_pending_intercepts = self.pending_intercepted_htlcs.lock().unwrap(); // Since some FundingNegotiation variants are not persisted, any splice in such state must - // be failed upon reload. However, as the necessary information for the SpliceFailed event - // is not persisted, the event itself needs to be persisted even though it hasn't been - // emitted yet. These are removed after the events are written. + // be failed upon reload. However, as the necessary information for the SpliceFailed and + // DiscardFunding events is not persisted, the events need to be persisted even though they + // haven't been emitted yet. These are removed after the events are written. let mut events = self.pending_events.lock().unwrap(); let event_count = events.len(); for peer_state in peer_states.iter() { @@ -17180,8 +17297,16 @@ impl< user_channel_id: chan.context.get_user_id(), abandoned_funding_txo: splice_funding_failed.funding_txo, channel_type: splice_funding_failed.channel_type, - contributed_inputs: splice_funding_failed.contributed_inputs, - contributed_outputs: splice_funding_failed.contributed_outputs, + }, + None, + )); + events.push_back(( + events::Event::DiscardFunding { + channel_id: chan.context().channel_id(), + funding_info: FundingInfo::Contribution { + inputs: splice_funding_failed.contributed_inputs, + outputs: splice_funding_failed.contributed_outputs, + }, }, None, )); @@ -17304,7 +17429,7 @@ impl< (21, WithoutLength(&self.flow.writeable_async_receive_offer_cache()), required), }); - // Remove the SpliceFailed events added earlier. + // Remove the SpliceFailed and DiscardFunding events added earlier. events.truncate(event_count); Ok(()) diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 262d0e37826..ed0ff797521 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -17,8 +17,8 @@ use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PathFailure, - PaymentFailureReason, PaymentPurpose, + ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice, + PathFailure, PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT, @@ -3205,16 +3205,48 @@ pub fn expect_splice_pending_event<'a, 'b, 'c, 'd>( pub fn expect_splice_failed_events<'a, 'b, 'c, 'd>( node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, funding_contribution: FundingContribution, +) { + let events = node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + match &events[0] { + Event::SpliceFailed { channel_id, .. } => { + assert_eq!(*expected_channel_id, *channel_id); + }, + _ => panic!("Unexpected event"), + } + match &events[1] { + Event::DiscardFunding { funding_info, .. } => { + if let FundingInfo::Contribution { inputs, outputs } = &funding_info { + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Unexpected event"), + } +} + +#[cfg(any(test, ldk_bench, feature = "_test_utils"))] +pub fn expect_discard_funding_event<'a, 'b, 'c, 'd>( + node: &'a Node<'b, 'c, 'd>, expected_channel_id: &ChannelId, + funding_contribution: FundingContribution, ) { let events = node.node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); match &events[0] { - Event::SpliceFailed { channel_id, contributed_inputs, contributed_outputs, .. } => { + Event::DiscardFunding { channel_id, funding_info } => { assert_eq!(*expected_channel_id, *channel_id); - let (expected_inputs, expected_outputs) = - funding_contribution.into_contributed_inputs_and_outputs(); - assert_eq!(*contributed_inputs, expected_inputs); - assert_eq!(*contributed_outputs, expected_outputs); + if let FundingInfo::Contribution { inputs, outputs } = &funding_info { + let (expected_inputs, expected_outputs) = + funding_contribution.into_contributed_inputs_and_outputs(); + assert_eq!(*inputs, expected_inputs); + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } }, _ => panic!("Unexpected event"), } diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs index a15b78c8b78..8bd186bff87 100644 --- a/lightning/src/ln/funding.rs +++ b/lightning/src/ln/funding.rs @@ -308,6 +308,14 @@ impl FundingContribution { self.is_splice } + pub(super) fn contributed_inputs(&self) -> impl Iterator + '_ { + self.inputs.iter().map(|input| input.utxo.outpoint) + } + + pub(super) fn contributed_outputs(&self) -> impl Iterator + '_ { + self.outputs.iter().chain(self.change_output.iter()) + } + pub(super) fn into_tx_parts(self) -> (Vec, Vec) { let FundingContribution { inputs, mut outputs, change_output, .. } = self; diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index bffa08184b9..e0e41bc9ba4 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -29,9 +29,12 @@ use crate::util::wallet_utils::{WalletSourceSync, WalletSync}; use crate::sync::Arc; +use bitcoin::hashes::Hash; use bitcoin::secp256k1::ecdsa::Signature; -use bitcoin::secp256k1::PublicKey; -use bitcoin::{Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut}; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use bitcoin::{ + Amount, FeeRate, OutPoint as BitcoinOutPoint, ScriptBuf, Transaction, TxOut, WPubkeyHash, +}; #[test] fn test_splicing_not_supported_api_error() { @@ -2056,7 +2059,7 @@ fn fail_splice_on_channel_close() { &nodes[0], &[ExpectedCloseEvent { channel_id: Some(channel_id), - discard_funding: false, + discard_funding: true, splice_failed: true, channel_funding_txo: None, user_channel_id: Some(42), @@ -2105,7 +2108,7 @@ fn fail_quiescent_action_on_channel_close() { &nodes[0], &[ExpectedCloseEvent { channel_id: Some(channel_id), - discard_funding: false, + discard_funding: true, splice_failed: true, channel_funding_txo: None, user_channel_id: Some(42), @@ -2494,3 +2497,397 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { ); check_added_monitors(&nodes[0], 1); } + +#[test] +fn test_funding_contributed_counterparty_not_found() { + // Tests that calling funding_contributed with an unknown counterparty_node_id returns + // ChannelUnavailable and emits a DiscardFunding event. + 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_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let contribution = SpliceContribution::splice_in(splice_in_amount); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + // Initiate splice to get a FundingNeeded event + nodes[0].node.splice_channel(&channel_id, &node_id_1, contribution, feerate).unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.build_sync(&wallet).unwrap(); + + // Use a fake/unknown public key as counterparty + let fake_node_id = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + assert_eq!( + nodes[0].node.funding_contributed( + &channel_id, + &fake_node_id, + funding_contribution.clone(), + None + ), + Err(APIError::ChannelUnavailable { + err: format!( + "Can't find a peer matching the passed counterparty node_id {}", + fake_node_id + ), + }) + ); + + expect_discard_funding_event(&nodes[0], &channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_channel_not_found() { + // Tests that calling funding_contributed with an unknown channel_id returns + // ChannelUnavailable and emits a DiscardFunding event. + 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_1 = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 50_000_000); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let contribution = SpliceContribution::splice_in(splice_in_amount); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + // Initiate splice to get a FundingNeeded event + nodes[0].node.splice_channel(&channel_id, &node_id_1, contribution, feerate).unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.build_sync(&wallet).unwrap(); + + // Use a random/unknown channel_id + let fake_channel_id = ChannelId::from_bytes([42; 32]); + + assert_eq!( + nodes[0].node.funding_contributed( + &fake_channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::ChannelUnavailable { + err: format!( + "Channel with id {} not found for the passed counterparty node_id {}", + fake_channel_id, node_id_1 + ), + }) + ); + + expect_discard_funding_event(&nodes[0], &fake_channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_splice_already_pending() { + // Tests that calling funding_contributed when there's already a pending splice + // contribution returns Err(APIMisuseError) and emits a DiscardFunding event containing only the + // inputs/outputs that are NOT already in the existing contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_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 splice_in_amount = Amount::from_sat(20_000); + // Provide enough UTXOs for two contributions + provide_utxo_reserves(&nodes, 2, splice_in_amount * 2); + + // Use splice_in_and_out with an output so we can test output filtering + let first_splice_out = TxOut { + value: Amount::from_sat(5_000), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + let first_contribution_spec = + SpliceContribution::splice_in_and_out(splice_in_amount, vec![first_splice_out.clone()]); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + // Initiate first splice to get a FundingNeeded event + nodes[0] + .node + .splice_channel(&channel_id, &node_id_1, first_contribution_spec, feerate) + .unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let first_funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + // Build the first contribution + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let first_contribution = first_funding_template.build_sync(&wallet).unwrap(); + + // Initiate a second splice with a DIFFERENT output to test that different outputs + // are included in DiscardFunding (not filtered out) + let second_splice_out = TxOut { + value: Amount::from_sat(6_000), // Different amount + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_raw_hash(Hash::all_zeros())), + }; + let second_contribution_spec = + SpliceContribution::splice_in_and_out(splice_in_amount, vec![second_splice_out.clone()]); + + // Clear UTXOs and add a LARGER one for the second contribution to ensure + // the change output will be different from the first contribution's change + nodes[0].wallet_source.clear_utxos(); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 3); + + nodes[0] + .node + .splice_channel(&channel_id, &node_id_1, second_contribution_spec, feerate) + .unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let second_funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let second_contribution = second_funding_template.build_sync(&wallet).unwrap(); + + // First funding_contributed - this sets up the quiescent action + nodes[0].node.funding_contributed(&channel_id, &node_id_1, first_contribution, None).unwrap(); + + // Drain the pending stfu message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Second funding_contributed with a different contribution - this should trigger + // DiscardFunding because there's already a pending quiescent action (splice contribution). + // Only inputs/outputs NOT in the existing contribution should be discarded. + let (expected_inputs, expected_outputs) = + second_contribution.clone().into_contributed_inputs_and_outputs(); + + // Returns Err(APIMisuseError) and emits DiscardFunding for the non-duplicate parts of the second contribution + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, second_contribution, None), + Err(APIError::APIMisuseError { + err: format!("Channel {} already has a pending funding contribution", channel_id), + }) + ); + + // The second contribution has different outputs (second_splice_out differs from first_splice_out), + // so those outputs should NOT be filtered out - they should appear in DiscardFunding. + let events = nodes[0].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::DiscardFunding { channel_id: event_channel_id, funding_info } => { + assert_eq!(event_channel_id, &channel_id); + if let FundingInfo::Contribution { inputs, outputs } = funding_info { + // The input is different, so it should be in the discard event + assert_eq!(*inputs, expected_inputs); + // The splice-out output is different (6000 vs 5000), so it should be in discard event + assert!(expected_outputs.contains(&second_splice_out)); + assert!(!expected_outputs.contains(&first_splice_out)); + // The different outputs should NOT be filtered out + assert_eq!(*outputs, expected_outputs); + } else { + panic!("Expected FundingInfo::Contribution"); + } + }, + _ => panic!("Expected DiscardFunding event"), + } +} + +#[test] +fn test_funding_contributed_duplicate_contribution_no_event() { + // Tests that calling funding_contributed with the exact same contribution twice + // returns Err(APIMisuseError) and emits no events on the second call (DoNothing path). + // This tests the case where all inputs/outputs in the second contribution + // are already present in the existing contribution. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let node_id_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 splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let contribution_spec = SpliceContribution::splice_in(splice_in_amount); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + // Initiate splice to get a FundingNeeded event + nodes[0].node.splice_channel(&channel_id, &node_id_1, contribution_spec, feerate).unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + // Build the contribution + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let contribution = funding_template.build_sync(&wallet).unwrap(); + + // First funding_contributed - this sets up the quiescent action + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap(); + + // Drain the pending stfu message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1); + + // Second funding_contributed with the SAME contribution (same inputs/outputs) + // This should trigger the DoNothing path because all inputs/outputs are duplicates. + // Returns Err(APIMisuseError) and emits NO events. + assert_eq!( + nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None), + Err(APIError::APIMisuseError { + err: format!("Duplicate funding contribution for channel {}", channel_id), + }) + ); + + // Verify no events were emitted - the duplicate contribution is silently ignored + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.is_empty(), "Expected no events for duplicate contribution, got {:?}", events); +} + +#[test] +fn test_funding_contributed_channel_shutdown() { + // Tests that calling funding_contributed after initiating channel shutdown returns Err(APIMisuseError) + // and emits both SpliceFailed and DiscardFunding events. The channel is no longer usable + // after shutdown is initiated, so quiescence cannot be proposed. + 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_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 splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let contribution = SpliceContribution::splice_in(splice_in_amount); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + // Initiate splice to get a FundingNeeded event + nodes[0].node.splice_channel(&channel_id, &node_id_1, contribution, feerate).unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.build_sync(&wallet).unwrap(); + + // Initiate channel shutdown - this makes is_usable() return false + nodes[0].node.close_channel(&channel_id, &node_id_1).unwrap(); + + // Drain the pending shutdown message + let _ = get_event_msg!(nodes[0], MessageSendEvent::SendShutdown, node_id_1); + + // Now call funding_contributed - this should trigger FailSplice because + // propose_quiescence() will fail when is_usable() returns false. + // Returns Err(APIMisuseError) and emits both SpliceFailed and DiscardFunding. + assert_eq!( + nodes[0].node.funding_contributed( + &channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::APIMisuseError { + err: format!("Channel {} cannot accept funding contribution", channel_id), + }) + ); + + expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution); +} + +#[test] +fn test_funding_contributed_unfunded_channel() { + // Tests that calling funding_contributed on an unfunded channel returns APIMisuseError + // and emits a DiscardFunding event. The channel exists but is not yet funded. + 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_1 = nodes[1].node.get_our_node_id(); + + // Create a funded channel for the splice operation + let (_, _, funded_channel_id, _) = + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 100_000, 0); + + // Create an unfunded channel (after open/accept but before funding tx) + let unfunded_channel_id = exchange_open_accept_chan(&nodes[0], &nodes[1], 50_000, 0); + + // Drain the FundingGenerationReady event for the unfunded channel + let _ = get_event!(nodes[0], Event::FundingGenerationReady); + + let splice_in_amount = Amount::from_sat(20_000); + provide_utxo_reserves(&nodes, 1, splice_in_amount * 2); + + let contribution = SpliceContribution::splice_in(splice_in_amount); + let feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64); + + // Initiate splice on the funded channel to get a FundingNeeded event + nodes[0].node.splice_channel(&funded_channel_id, &node_id_1, contribution, feerate).unwrap(); + + let event = get_event!(nodes[0], Event::FundingNeeded); + let funding_template = match event { + Event::FundingNeeded { funding_template, .. } => funding_template, + _ => panic!("Expected Event::FundingNeeded"), + }; + + let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger); + let funding_contribution = funding_template.build_sync(&wallet).unwrap(); + + // Call funding_contributed with the unfunded channel's ID instead of the funded one. + // Returns APIMisuseError because the channel is not funded. + assert_eq!( + nodes[0].node.funding_contributed( + &unfunded_channel_id, + &node_id_1, + funding_contribution.clone(), + None + ), + Err(APIError::APIMisuseError { + err: format!( + "Channel with id {} not expecting funding contribution", + unfunded_channel_id + ), + }) + ); + + expect_discard_funding_event(&nodes[0], &unfunded_channel_id, funding_contribution); +}