diff --git a/Cargo.toml b/Cargo.toml index 98bf30683bc..8999e420571 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,12 @@ exclude = [ # worth it. Note that we only apply optimizations to dependencies, not workspace # crates themselves. # https://doc.rust-lang.org/cargo/reference/profiles.html#profile-selection +# Experimental `OP_TEMPLATEHASH` (BIP-446/448) support used by `option_htlcs_claim_tx` is not yet +# available in a released `bitcoin`; pull it from a fork that adds it. The BIP-446/448 commits are +# cherry-picked onto the same `bitcoin` patch release (0.32.101) that we otherwise depend on. +[patch.crates-io] +bitcoin = { git = "https://github.com/darosior/rust-bitcoin", branch = "bip448-0.32.4" } + [profile.dev.package."*"] opt-level = 2 diff --git a/lightning-types/src/features.rs b/lightning-types/src/features.rs index 21d59b2b917..fc77a2db9bd 100644 --- a/lightning-types/src/features.rs +++ b/lightning-types/src/features.rs @@ -83,6 +83,8 @@ //! (see [BOLT PR #1160](https://github.com/lightning/bolts/pull/1160) for more information). //! - `HtlcHold` - requires/supports holding HTLCs and forwarding on receipt of an onion message //! (see [BOLT-2](https://github.com/lightning/bolts/pull/989/files) for more information). +//! - `OptionHTLCsClaimTx` - requires/supports committing to a v3 claim transaction in the preimage +//! spend path of offered HTLC outputs, closing the last pinning gap (experimental). //! //! LDK knows about the following features, but does not support them: //! - `AnchorsNonzeroFeeHtlcTx` - the initial version of anchor outputs, which was later found to be @@ -167,8 +169,12 @@ mod sealed { ZeroConf, // Byte 7 Trampoline | SimpleClose | Splice, - // Byte 8 - 18 - ,,,,,,,,,,, + // Byte 8 - 12 + ,,,,, + // Byte 13 + OptionHTLCsClaimTx, + // Byte 14 - 18 + ,,,,, // Byte 19 HtlcHold, ] @@ -192,8 +198,12 @@ mod sealed { ZeroConf | Keysend, // Byte 7 Trampoline | SimpleClose | Splice, - // Byte 8 - 18 - ,,,,,,,,,,, + // Byte 8 - 12 + ,,,,, + // Byte 13 + OptionHTLCsClaimTx, + // Byte 14 - 18 + ,,,,, // Byte 19 HtlcHold, // Byte 20 - 31 @@ -259,6 +269,10 @@ mod sealed { AnchorZeroFeeCommitments | SCIDPrivacy, // Byte 6 ZeroConf, + // Byte 7 - 12 + ,,,,,, + // Byte 13 + OptionHTLCsClaimTx, ]); /// Defines a feature with the given bits for the specified [`Context`]s. The generated trait is @@ -706,6 +720,17 @@ mod sealed { // By default, allocate enough bytes to cover up to Splice. Update this as new features are // added which we expect to appear commonly across contexts. pub(super) const MIN_FEATURES_ALLOCATION_BYTES: usize = 63_usize.div_ceil(8); + define_feature!( + 111, + OptionHTLCsClaimTx, + [InitContext, NodeContext, ChannelTypeContext], + "Feature flags for `option_htlcs_claim_tx`.", + set_htlcs_claim_tx_optional, + set_htlcs_claim_tx_required, + clear_htlcs_claim_tx, + supports_htlcs_claim_tx, + requires_htlcs_claim_tx + ); define_feature!( 153, // The BOLTs PR uses feature bit 52/53, so add +100 for the experimental bit HtlcHold, diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index 42d04e0f8ce..3780dfc679a 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -4648,6 +4648,30 @@ impl ChannelMonitorImpl { tx_lock_time, })); } + ClaimEvent::BumpHTLCsClaimTx { + target_feerate_sat_per_1000_weight, claim_tx, channel_parameters, + } => { + let channel_id = self.channel_id; + let counterparty_node_id = self.counterparty_node_id; + let channel_value_satoshis = channel_parameters.channel_value_satoshis; + // The claim transaction's single output (a P2WPKH to our payment point) is what + // the fee-paying child spends to bump the package fee. + let claim_output_descriptor = StaticPaymentOutputDescriptor { + outpoint: OutPoint { txid: claim_tx.compute_txid(), index: 0 }, + output: claim_tx.output[0].clone(), + channel_keys_id: self.channel_keys_id, + channel_value_satoshis, + channel_transaction_parameters: Some(channel_parameters), + }; + ret.push(Event::BumpTransaction(BumpTransactionEvent::HTLCsClaimTxResolution { + channel_id, + counterparty_node_id, + claim_id, + package_target_feerate_sat_per_1000_weight: target_feerate_sat_per_1000_weight, + claim_tx, + claim_output_descriptor, + })); + } } } ret @@ -6218,7 +6242,17 @@ impl ChannelMonitorImpl { let mut payment_preimage = PaymentPreimage([0; 32]); if offered_preimage_claim || accepted_preimage_claim { - payment_preimage.0.copy_from_slice(input.witness.second_to_last().unwrap()); + // For legacy P2WSH HTLC claims the 32-byte preimage is the second-to-last witness + // element. For `option_htlcs_claim_tx` templated (P2TR script-path) offered-HTLC + // claims the witness is `[preimage, htlc_success_script, control_block]`, so the + // preimage is instead the first element. + let second_to_last = input.witness.second_to_last().unwrap(); + let preimage = if second_to_last.len() == 32 { + second_to_last + } else { + input.witness.nth(0).unwrap() + }; + payment_preimage.0.copy_from_slice(preimage); } macro_rules! log_claim { diff --git a/lightning/src/chain/onchaintx.rs b/lightning/src/chain/onchaintx.rs index 3eb6d64f3a2..e83c1796a37 100644 --- a/lightning/src/chain/onchaintx.rs +++ b/lightning/src/chain/onchaintx.rs @@ -195,6 +195,16 @@ pub(crate) enum ClaimEvent { htlcs: Vec, tx_lock_time: LockTime, }, + /// Event yielded to signal that an `option_htlcs_claim_tx` offered HTLC must be resolved by + /// broadcasting the fixed, zero-fee, template-committed v3 claim transaction alongside a + /// fee-paying child (a TRUC 1-parent-1-child package). + BumpHTLCsClaimTx { + target_feerate_sat_per_1000_weight: u32, + /// The fully-signed, template-committed, zero-fee v3 HTLC claim transaction to be confirmed + /// via a fee-paying child. + claim_tx: Transaction, + channel_parameters: ChannelTransactionParameters, + }, } /// Represents the different ways an output can be claimed (i.e., spent to an address under our @@ -740,8 +750,35 @@ impl OnchainTxHandler { None => Some((new_timer, 0, OnchainClaim::Tx(MaybeSignedTransaction(tx)))), } }, + // `option_htlcs_claim_tx`: the offered HTLC is resolved by broadcasting the fixed, + // zero-fee, template-committed v3 claim transaction. Since it pays no fee, it can + // only be relayed and confirmed alongside a fee-paying child (TRUC 1P1C), which we + // request from the user through a `BumpHTLCsClaimTx` event. + PackageSolvingData::CounterpartyOfferedHTLCOutput(output) => { + debug_assert!(output.channel_type_features().supports_htlcs_claim_tx()); + let claim_tx = cached_request.maybe_finalize_untractable_package(self, logger)?; + if !claim_tx.is_fully_signed() { + // We couldn't sign the claim transaction as the signer was unavailable, but + // we should still retry it later. We return the unsigned transaction anyway + // to register the claim. + return Some((new_timer, 0, OnchainClaim::Tx(claim_tx))); + } + let target_feerate_sat_per_1000_weight = cached_request + .compute_package_feerate(fee_estimator, conf_target, feerate_strategy); + let channel_parameters = output.channel_parameters() + .unwrap_or(self.channel_parameters()).clone(); + Some(( + new_timer, + target_feerate_sat_per_1000_weight as u64, + OnchainClaim::Event(ClaimEvent::BumpHTLCsClaimTx { + target_feerate_sat_per_1000_weight, + claim_tx: claim_tx.0, + channel_parameters, + }), + )) + }, _ => { - debug_assert!(false, "Only HolderFundingOutput inputs should be untractable and require external funding"); + debug_assert!(false, "Only HolderFundingOutput and option_htlcs_claim_tx offered HTLC inputs should be untractable and require external funding"); None }, }) @@ -909,6 +946,10 @@ impl OnchainTxHandler { // underlying set of HTLCs changes. ClaimId::from_htlcs(htlcs) }, + ClaimEvent::BumpHTLCsClaimTx { ref claim_tx, .. } => + // The template-committed claim transaction spends a single HTLC + // output, so its txid is unique per request. + ClaimId(claim_tx.compute_txid().to_byte_array()), }; debug_assert!(self.pending_claim_requests.get(&claim_id).is_none()); debug_assert_eq!(self.pending_claim_events.iter().filter(|entry| entry.0 == claim_id).count(), 0); diff --git a/lightning/src/chain/package.rs b/lightning/src/chain/package.rs index 0ef8855242b..f0061259972 100644 --- a/lightning/src/chain/package.rs +++ b/lightning/src/chain/package.rs @@ -98,6 +98,7 @@ pub(crate) fn verify_channel_type_features(channel_type_features: &Option &ChannelTypeFeatures { + &self.channel_type_features + } + + /// The channel parameters of the channel this offered HTLC output belongs to, if known. May be + /// `None` for outputs deserialized from monitors written before LDK 0.2. + pub(crate) fn channel_parameters(&self) -> Option<&ChannelTransactionParameters> { + self.channel_parameters.as_ref() + } + + /// Builds the templated v3 HTLC claim transaction spending this offered HTLC output via the + /// preimage (`htlc_success`) path, as required by `option_htlcs_claim_tx`. + /// + /// Unlike the malleable sweep used for other offered HTLC outputs, this transaction is fixed + /// (a single input spending `outpoint`, a single zero-fee P2WPKH output for the full HTLC + /// value), broadcast verbatim, and committed to via `OP_TEMPLATEHASH`. It carries no + /// counterparty signature: satisfaction is the preimage plus the leaf script. + /// + /// Because the claim transaction pays zero fees, it can only be relayed and confirmed alongside + /// a fee-paying child spending its P2WPKH output (a TRUC 1-parent-1-child package). The + /// `OnchainTxHandler` therefore yields it as a `ClaimEvent::BumpHTLCsClaimTx` (surfaced to the + /// user as a `BumpTransactionEvent::HTLCsClaimTxResolution`) rather than broadcasting it on its + /// own. + #[rustfmt::skip] + pub(crate) fn get_maybe_signed_htlcs_claim_tx( + &self, onchain_handler: &mut OnchainTxHandler, outpoint: &BitcoinOutPoint, + ) -> Option { + let channel_parameters = onchain_handler.channel_parameters(); + let channel_parameters = self.channel_parameters.as_ref().unwrap_or(channel_parameters); + debug_assert!(channel_parameters.channel_type_features.supports_htlcs_claim_tx()); + let directed_parameters = channel_parameters.as_counterparty_broadcastable(); + let chan_keys = TxCreationKeys::from_channel_static_keys( + &self.per_commitment_point, directed_parameters.broadcaster_pubkeys(), + directed_parameters.countersignatory_pubkeys(), &onchain_handler.secp_ctx, + ); + let countersignatory_payment_point = + &directed_parameters.countersignatory_pubkeys().payment_point; + + let mut claim_tx = chan_utils::build_htlc_claim_transaction( + *outpoint, &self.htlc, countersignatory_payment_point, + ); + let spend_info = chan_utils::offered_htlc_taproot_spend_info( + &self.htlc, &chan_keys.revocation_key, &chan_keys.broadcaster_htlc_key, + &chan_keys.countersignatory_htlc_key, countersignatory_payment_point, + ); + let (_htlc_timeout, htlc_success) = chan_utils::offered_htlc_tapscript_leaves( + &self.htlc, &chan_keys.broadcaster_htlc_key, &chan_keys.countersignatory_htlc_key, + countersignatory_payment_point, + ); + claim_tx.input[0].witness = chan_utils::build_htlcs_claim_tx_witness( + &self.preimage, &spend_info, &htlc_success, + ); + Some(MaybeSignedTransaction(claim_tx)) + } } impl Writeable for CounterpartyOfferedHTLCOutput { @@ -941,6 +998,9 @@ impl PackageSolvingData { directed_parameters.broadcaster_pubkeys().htlc_basepoint, outp.counterparty_htlc_base_key, ); + // `option_htlcs_claim_tx` offered HTLC outputs are untractable (resolved by + // broadcasting the fixed HTLC claim transaction) and so are never finalized here. + debug_assert!(!channel_parameters.channel_type_features.supports_htlcs_claim_tx()); let chan_keys = TxCreationKeys::from_channel_static_keys( &outp.per_commitment_point, directed_parameters.broadcaster_pubkeys(), directed_parameters.countersignatory_pubkeys(), &onchain_handler.secp_ctx, @@ -1000,6 +1060,11 @@ impl PackageSolvingData { PackageSolvingData::HolderFundingOutput(ref outp) => { Some(outp.get_maybe_signed_commitment_tx(onchain_handler)) } + PackageSolvingData::CounterpartyOfferedHTLCOutput(ref outp) => { + // Only `option_htlcs_claim_tx` offered HTLC outputs are untractable and reach here. + debug_assert!(outp.channel_type_features.supports_htlcs_claim_tx()); + outp.get_maybe_signed_htlcs_claim_tx(onchain_handler, outpoint) + } _ => { panic!("API Error!"); } } } @@ -1043,8 +1108,16 @@ impl PackageSolvingData { PackageMalleability::Malleable(AggregationCluster::Pinnable) } }, - PackageSolvingData::CounterpartyOfferedHTLCOutput(..) => - PackageMalleability::Malleable(AggregationCluster::Unpinnable), + PackageSolvingData::CounterpartyOfferedHTLCOutput(ref outp) => { + if outp.channel_type_features.supports_htlcs_claim_tx() { + // `option_htlcs_claim_tx`: the output is resolved by broadcasting a fixed, + // templated zero-fee claim transaction (fee-bumped by a child). It is committed + // to via `OP_TEMPLATEHASH` and so cannot be aggregated or RBF-bumped. + PackageMalleability::Untractable + } else { + PackageMalleability::Malleable(AggregationCluster::Unpinnable) + } + }, PackageSolvingData::CounterpartyReceivedHTLCOutput(..) => PackageMalleability::Malleable(AggregationCluster::Pinnable), PackageSolvingData::HolderHTLCOutput(ref outp) => { @@ -1581,6 +1654,11 @@ impl PackageTemplate { outp.channel_type_features.supports_anchors_zero_fee_htlc_tx() || outp.channel_type_features.supports_anchor_zero_fee_commitments() }, + PackageSolvingData::CounterpartyOfferedHTLCOutput(ref outp) => { + // `option_htlcs_claim_tx` offered HTLC outputs are resolved by broadcasting a + // fixed, zero-fee claim transaction that must be fee-bumped via a child (CPFP). + outp.channel_type_features.supports_htlcs_claim_tx() + }, _ => false, }).is_some() } diff --git a/lightning/src/events/bump_transaction/mod.rs b/lightning/src/events/bump_transaction/mod.rs index 79f5aced1b6..b3cc1ce73e4 100644 --- a/lightning/src/events/bump_transaction/mod.rs +++ b/lightning/src/events/bump_transaction/mod.rs @@ -29,7 +29,10 @@ use crate::ln::chan_utils::{ use crate::ln::types::ChannelId; use crate::prelude::*; use crate::sign::ecdsa::EcdsaChannelSigner; -use crate::sign::{ChannelDerivationParameters, HTLCDescriptor, SignerProvider}; +use crate::sign::{ + ChannelDerivationParameters, HTLCDescriptor, SignerProvider, StaticPaymentOutputDescriptor, + P2WPKH_WITNESS_WEIGHT, +}; use crate::util::logger::Logger; use crate::util::wallet_utils::{CoinSelection, CoinSelectionSource, ConfirmedUtxo, Input}; @@ -242,6 +245,53 @@ pub enum BumpTransactionEvent { /// The locktime required for the resulting HTLC transaction. tx_lock_time: LockTime, }, + /// Indicates that an offered HTLC on a confirmed counterparty commitment of an + /// `option_htlcs_claim_tx` channel must be resolved via the preimage path by broadcasting the + /// fixed, template-committed HTLC claim transaction. Because that claim transaction commits to + /// itself through `OP_TEMPLATEHASH`, it cannot have inputs or outputs added to it, and because + /// it pays zero fees it must be confirmed alongside a fee-paying child spending its output as a + /// TRUC (BIP 431) 1-parent-1-child package. The child transaction must be version 3 and no more + /// than 1000 vB. + /// + /// The `claim_tx` is fully signed and must be broadcast as-is. The consumer of this event must + /// construct the child transaction so that it spends `claim_output_descriptor` (the claim + /// transaction's single output, a P2WPKH to our payment point) along with any additional + /// confirmed inputs needed to meet `package_target_feerate_sat_per_1000_weight` over the whole + /// package, and then broadcast both transactions together (usually via the Bitcoin Core + /// `submitpackage` RPC). To sign the input spending `claim_output_descriptor`, an + /// [`EcdsaChannelSigner`] should be re-derived through [`SignerProvider::derive_channel_signer`] + /// and [`EcdsaChannelSigner::sign_htlcs_claim_transaction_input`] used to obtain the witness. + /// + /// It is possible to receive more than one instance of this event if a valid child transaction + /// is never broadcast or is but not with a sufficient fee to be mined. Care should be taken to + /// ensure any future iterations of the child transaction adhere to the [Replace-By-Fee + /// rules](https://github.com/bitcoin/bitcoin/blob/master/doc/policy/mempool-replacements.md). + /// + /// [`EcdsaChannelSigner`]: crate::sign::ecdsa::EcdsaChannelSigner + /// [`EcdsaChannelSigner::sign_htlcs_claim_transaction_input`]: crate::sign::ecdsa::EcdsaChannelSigner::sign_htlcs_claim_transaction_input + HTLCsClaimTxResolution { + /// The `channel_id` of the channel which has been closed. + channel_id: ChannelId, + /// Counterparty in the closed channel. + counterparty_node_id: PublicKey, + /// The unique identifier for the claim of the offered HTLC in the confirmed commitment + /// transaction. + /// + /// The identifier must map to the set of external UTXOs assigned to the claim, such that + /// they can be reused when a new claim with the same identifier needs to be made, resulting + /// in a fee-bumping attempt. + claim_id: ClaimId, + /// The target feerate that the transaction package, which consists of the HTLC claim + /// transaction and the to-be-crafted fee-paying child transaction, must meet. + package_target_feerate_sat_per_1000_weight: u32, + /// The fully-signed, template-committed, zero-fee version 3 HTLC claim transaction. This + /// transaction must be broadcast as-is, together with the fee-paying child constructed as a + /// result of consuming this event. + claim_tx: Transaction, + /// The descriptor for the HTLC claim transaction's single output, spent by the fee-paying + /// child to anchor the CPFP fee bump. + claim_output_descriptor: StaticPaymentOutputDescriptor, + }, } /// A handler for [`Event::BumpTransaction`] events that sources confirmed UTXOs from a @@ -778,6 +828,146 @@ impl Result<(), ()> { + // The HTLC claim transaction is template-committed (a single input and a single output) and + // pays zero fees, so it cannot be modified; instead we attach a fee-paying child spending + // its output, forming a TRUC (BIP 431) 1-parent-1-child package. + let claim_output = claim_output_descriptor.output.clone(); + let claim_tx_weight = claim_tx.weight().to_wu(); + + // We fold the (zero-fee) claim transaction's weight into the child's spent claim output, so + // that coin selection meets the package feerate over both transactions. + let starting_input_satisfaction_weight = + claim_tx_weight + P2WPKH_WITNESS_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT; + let mut input_satisfaction_weight_with_parent = starting_input_satisfaction_weight; + + loop { + let must_spend = vec![Input { + outpoint: claim_output_descriptor.outpoint.into_bitcoin_outpoint(), + previous_utxo: claim_output.clone(), + satisfaction_weight: input_satisfaction_weight_with_parent, + }]; + let must_spend_amount = claim_output.value; + + log_debug!(self.logger, "Performing coin selection for HTLC claim package (claim and child transaction) targeting {} sat/kW", + package_target_feerate_sat_per_1000_weight); + let coin_selection: CoinSelection = self + .utxo_source + .select_confirmed_utxos( + Some(claim_id), + must_spend, + &[], + package_target_feerate_sat_per_1000_weight, + // We added the claim tx weight to the input satisfaction weight above, so + // increase the max_tx_weight by the same delta here. + TRUC_CHILD_MAX_WEIGHT + claim_tx_weight, + ) + .await?; + + let mut child_tx = Transaction { + version: Version::non_standard(3), + lock_time: LockTime::ZERO, // TODO: Use next best height. + input: vec![TxIn { + previous_output: claim_output_descriptor.outpoint.into_bitcoin_outpoint(), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![], + }; + + let input_satisfaction_weight = coin_selection.satisfaction_weight(); + let total_satisfaction_weight = + P2WPKH_WITNESS_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT + input_satisfaction_weight; + let total_input_amount = must_spend_amount + coin_selection.input_amount(); + + self.process_coin_selection(&mut child_tx, &coin_selection); + let child_txid = child_tx.compute_txid(); + + // construct psbt + let mut child_psbt = Psbt::from_unsigned_tx(child_tx).unwrap(); + // add witness_utxo to the claim output input + child_psbt.inputs[0].witness_utxo = Some(claim_output.clone()); + // add witness_utxo to remaining inputs + for (idx, utxo) in coin_selection.confirmed_utxos.into_iter().enumerate() { + // add 1 to skip the claim output input + let index = idx + 1; + debug_assert_eq!( + child_psbt.unsigned_tx.input[index].previous_output, + utxo.outpoint() + ); + if utxo.output().script_pubkey.is_witness_program() { + child_psbt.inputs[index].witness_utxo = Some(utxo.into_output()); + } + } + + debug_assert_eq!(child_psbt.unsigned_tx.output.len(), 1); + let unsigned_tx_weight = child_psbt.unsigned_tx.weight().to_wu() + - (child_psbt.unsigned_tx.input.len() as u64 * EMPTY_SCRIPT_SIG_WEIGHT); + + let package_fee = total_input_amount + - child_psbt.unsigned_tx.output.iter().map(|output| output.value).sum(); + let package_weight = unsigned_tx_weight + 2 /* wit marker */ + total_satisfaction_weight + claim_tx_weight; + if package_fee.to_sat() * 1000 / package_weight + < package_target_feerate_sat_per_1000_weight.into() + { + // On the first iteration of the loop, we may undershoot the target feerate because + // we had to add an OP_RETURN output in `process_coin_selection` which we didn't + // select sufficient coins for. Here we detect that case and go around again seeking + // additional weight. + if input_satisfaction_weight_with_parent == starting_input_satisfaction_weight { + debug_assert!( + child_psbt.unsigned_tx.output[0].script_pubkey.is_op_return(), + "Coin selection failed to select sufficient coins for its change output" + ); + input_satisfaction_weight_with_parent += + child_psbt.unsigned_tx.output[0].weight().to_wu(); + continue; + } else { + debug_assert!(false, "Coin selection failed to select sufficient coins"); + } + } + + log_debug!(self.logger, "Signing HTLC claim child transaction {}", child_txid); + let mut child_tx = self.utxo_source.sign_psbt(child_psbt).await?; + + // Sign the input spending the claim transaction's output via the channel signer. + let signer = + self.signer_provider.derive_channel_signer(claim_output_descriptor.channel_keys_id); + child_tx.input[0].witness = signer.sign_htlcs_claim_transaction_input( + &child_tx, + 0, + claim_output_descriptor, + &self.secp, + )?; + + #[cfg(debug_assertions)] + { + assert!(claim_tx_weight < TRUC_MAX_WEIGHT); + assert!(child_tx.weight().to_wu() < TRUC_CHILD_MAX_WEIGHT); + } + + log_info!( + self.logger, + "Broadcasting HTLC claim transaction {} with fee-bumping child {}", + claim_tx.compute_txid(), + child_txid + ); + self.broadcaster.broadcast_transactions(&[ + (claim_tx, TransactionType::Claim { counterparty_node_id, channel_id }), + (&child_tx, TransactionType::AnchorBump { counterparty_node_id, channel_id }), + ]); + return Ok(()); + } + } + /// Handles all variants of [`BumpTransactionEvent`]. pub async fn handle_event(&self, event: &BumpTransactionEvent) { match event { @@ -847,6 +1037,37 @@ impl { + log_info!( + self.logger, + "Handling HTLC claim transaction bump (claim_id = {}, claim_txid = {})", + log_bytes!(claim_id.0), + claim_tx.compute_txid() + ); + self.handle_htlcs_claim_tx_resolution( + *channel_id, + *counterparty_node_id, + *claim_id, + *package_target_feerate_sat_per_1000_weight, + claim_tx, + claim_output_descriptor, + ) + .await + .unwrap_or_else(|_| { + log_error!( + self.logger, + "Failed bumping HTLC claim transaction {}", + claim_tx.compute_txid() + ); + }); + }, } } } @@ -1006,6 +1227,95 @@ mod tests { }); } + #[test] + fn test_htlcs_claim_tx_resolution() { + // Test that an `option_htlcs_claim_tx` HTLC claim transaction is fee-bumped by broadcasting + // a version 3 child spending its output, as a TRUC 1-parent-1-child package. + use crate::ln::chan_utils::get_countersigner_payment_script; + use crate::sign::ChannelSigner; + + let secp = Secp256k1::new(); + let keys_id = [42; 32]; + let signer = KeysManager::new(&[42; 32], 42, 42, true); + let payment_point = signer.derive_channel_signer(keys_id).pubkeys(&secp).payment_point; + + let mut channel_type = ChannelTypeFeatures::only_static_remote_key(); + channel_type.set_anchor_zero_fee_commitments_required(); + channel_type.set_htlcs_claim_tx_required(); + // The claim transaction's output is a plain P2WPKH to our payment point. + let claim_output_script = get_countersigner_payment_script(&channel_type, &payment_point); + + // A zero-fee version 3 claim transaction paying the full HTLC value to our payment point. + const CLAIM_VALUE: u64 = 1_000_000; + let claim_tx = Transaction { + version: Version::non_standard(3), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::null(), + script_sig: ScriptBuf::new(), + sequence: Sequence(0), + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(CLAIM_VALUE), + script_pubkey: claim_output_script.clone(), + }], + }; + let claim_txid = claim_tx.compute_txid(); + let expected_must_spend_weight = + claim_tx.weight().to_wu() + P2WPKH_WITNESS_WEIGHT + EMPTY_SCRIPT_SIG_WEIGHT; + + let mut transaction_parameters = ChannelTransactionParameters::test_dummy(42_000_000); + transaction_parameters.channel_type_features = channel_type; + let claim_output_descriptor = StaticPaymentOutputDescriptor { + outpoint: crate::chain::transaction::OutPoint { txid: claim_txid, index: 0 }, + output: claim_tx.output[0].clone(), + channel_keys_id: keys_id, + channel_value_satoshis: 42_000_000, + channel_transaction_parameters: Some(transaction_parameters), + }; + + // The child keeps the claimed value minus a comfortable fee as a change output, so the + // package easily clears the (low) target feerate without needing external coins. + let target_feerate = 253; + let change_output = TxOut { + value: Amount::from_sat(CLAIM_VALUE - 5_000), + script_pubkey: claim_output_script, + }; + let broadcaster = TestBroadcaster::new(Network::Testnet); + let source = TestCoinSelectionSource { + expected_selects: Mutex::new(vec![( + expected_must_spend_weight, + CLAIM_VALUE, + target_feerate, + CoinSelection { confirmed_utxos: Vec::new(), change_output: Some(change_output) }, + )]), + }; + let logger = TestLogger::new(); + let handler = BumpTransactionEventHandlerSync::new(&broadcaster, &source, &signer, &logger); + + handler.handle_event(&BumpTransactionEvent::HTLCsClaimTxResolution { + channel_id: ChannelId([42; 32]), + counterparty_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + claim_id: ClaimId([42; 32]), + package_target_feerate_sat_per_1000_weight: target_feerate, + claim_tx: claim_tx.clone(), + claim_output_descriptor, + }); + + // The claim transaction and its fee-paying child are broadcast together as a package. + let broadcasted = broadcaster.txn_broadcast(); + assert_eq!(broadcasted.len(), 2); + assert_eq!(broadcasted[0], claim_tx); + let child = &broadcasted[1]; + assert_eq!(child.version, Version::non_standard(3)); + assert_eq!(child.input.len(), 1); + assert_eq!(child.input[0].previous_output, OutPoint { txid: claim_txid, vout: 0 }); + // The child input spending the claim output is signed (P2WPKH: signature + pubkey). + assert_eq!(child.input[0].witness.len(), 2); + assert_eq!(child.output.len(), 1); + } + #[test] fn test_utxo_new_v1_p2tr() { // Transaction 33e794d097969002ee05d336686fc03c9e15a597c1b9827669460fac98799036 diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 271e135d51d..14041a248a1 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -2388,10 +2388,11 @@ impl Writeable for Event { &Event::BumpTransaction(ref event) => { 27u8.write(writer)?; match event { - // We never write the ChannelClose|HTLCResolution events as they'll be replayed - // upon restarting anyway if they remain unresolved. + // We never write the ChannelClose|HTLCResolution|HTLCsClaimTxResolution events + // as they'll be replayed upon restarting anyway if they remain unresolved. BumpTransactionEvent::ChannelClose { .. } => {}, BumpTransactionEvent::HTLCResolution { .. } => {}, + BumpTransactionEvent::HTLCsClaimTxResolution { .. } => {}, } write_tlv_fields!(writer, {}); // Write a length field for forwards compat }, diff --git a/lightning/src/ln/chan_utils.rs b/lightning/src/ln/chan_utils.rs index 4bb8ffac9ef..e57e9b3fe78 100644 --- a/lightning/src/ln/chan_utils.rs +++ b/lightning/src/ln/chan_utils.rs @@ -16,6 +16,10 @@ use bitcoin::opcodes; use bitcoin::script::{Builder, Script, ScriptBuf}; use bitcoin::sighash; use bitcoin::sighash::EcdsaSighashType; +use bitcoin::taproot::{ + LeafVersion, TaprootBuilder, TaprootSpendInfo, TAPROOT_CONTROL_BASE_SIZE, + TAPROOT_CONTROL_NODE_SIZE, +}; use bitcoin::transaction::Version; use bitcoin::transaction::{OutPoint, Transaction, TxIn, TxOut}; use bitcoin::{PubkeyHash, WPubkeyHash}; @@ -206,6 +210,30 @@ pub enum HTLCClaim { Revocation, } +/// Whether `witness` spends an `option_htlcs_claim_tx` offered HTLC output through the preimage +/// (`htlc_success`) path. Unlike the legacy P2WSH HTLC claims, this is a P2TR script-path spend of +/// the form `[preimage, htlc_success_script, control_block]`, where the leaf script commits, via +/// `OP_TEMPLATEHASH`, to the fixed HTLC claim transaction (see [`offered_htlc_tapscript_leaves`]). +pub(crate) fn is_htlcs_claim_tx_offered_preimage_witness(witness: &Witness) -> bool { + if witness.len() != 3 { + return false; + } + // `[preimage(32), htlc_success_script, control_block]`. + let (Some(preimage), Some(leaf_script), Some(control_block)) = + (witness.nth(0), witness.second_to_last(), witness.last()) + else { + return false; + }; + // The witness elements must have the shape produced by `build_htlcs_claim_tx_witness`: a + // 32-byte preimage, a well-formed taproot control block, and the `htlc_success` leaf script, + // which is uniquely identified by its trailing `OP_TEMPLATEHASH OP_EQUAL`. + preimage.len() == 32 + && control_block.len() >= TAPROOT_CONTROL_BASE_SIZE + && (control_block.len() - TAPROOT_CONTROL_BASE_SIZE) % TAPROOT_CONTROL_NODE_SIZE == 0 + && leaf_script + .ends_with(&[opcodes::all::OP_TEMPLATEHASH.to_u8(), opcodes::all::OP_EQUAL.to_u8()]) +} + impl HTLCClaim { /// Check if a given input witness attempts to claim a HTLC. #[rustfmt::skip] @@ -214,6 +242,12 @@ impl HTLCClaim { if witness.len() < 2 { return None; } + // `option_htlcs_claim_tx`: an offered HTLC claimed via the preimage path is a P2TR + // script-path spend, structurally distinct from the legacy P2WSH HTLC witnesses handled + // below (its preimage is the *first* witness element, not the second-to-last). + if is_htlcs_claim_tx_offered_preimage_witness(witness) { + return Some(Self::OfferedPreimage); + } let witness_script = witness.last().unwrap(); let second_to_last = witness.second_to_last().unwrap(); if witness_script.len() == OFFERED_HTLC_SCRIPT_WEIGHT { @@ -830,6 +864,173 @@ pub fn get_htlc_redeemscript(htlc: &HTLCOutputInCommitment, channel_type_feature get_htlc_redeemscript_with_explicit_keys(htlc, channel_type_features, &keys.broadcaster_htlc_key, &keys.countersignatory_htlc_key, &keys.revocation_key) } +/// Computes the BIP-446 `OP_TEMPLATEHASH` digest for the given transaction and input index. +/// +/// The template hash commits to the version, locktime, input sequences, outputs and the spending +/// input index, but *not* to the prevouts, which is what avoids a commitment cycle. We do not use an +/// annex here. +/// +/// Because the prevouts are not committed to, the hash is independent of the commitment transaction +/// that the HTLC claim transaction spends, so it can be computed while building the offered HTLC +/// output (before the commitment txid is known). +pub(crate) fn get_template_hash(tx: &Transaction, input_index: u32) -> [u8; 32] { + let mut cache = sighash::SighashCache::new(tx); + cache + .template_hash(input_index as usize, None) + .expect("input index is within bounds") + .to_byte_array() +} + +/// Builds the v3 "HTLC claim transaction" used to resolve an offered HTLC output via the preimage +/// path when `option_htlcs_claim_tx` applies. +/// +/// The transaction spends a single offered HTLC output and pays its entire value (the HTLC +/// `amount_msat` divided by 1000, rounding down) to a P2WPKH for the remote node's `payment_point`, +/// paying zero fees (it is fee-bumped via a TRUC/v3 child). +/// +/// `commitment_outpoint` is only relevant when broadcasting; [`get_template_hash`] does not commit +/// to it, so [`OutPoint::null`] may be passed when computing the templated `htlc_success` script. +pub fn build_htlc_claim_transaction( + commitment_outpoint: OutPoint, htlc: &HTLCOutputInCommitment, + countersignatory_payment_point: &PublicKey, +) -> Transaction { + Transaction { + version: Version::non_standard(3), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: commitment_outpoint, + script_sig: ScriptBuf::new(), + sequence: Sequence(0), + witness: Witness::new(), + }], + output: vec![TxOut { + value: htlc.to_bitcoin_amount(), + script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::hash( + &countersignatory_payment_point.serialize(), + )), + }], + } +} + +/// The two tapscript leaves of an offered HTLC output when `option_htlcs_claim_tx` applies: the +/// `htlc_timeout` leaf (a 2-of-2 between the two HTLC keys) and the `htlc_success` leaf (which +/// commits, via `OP_TEMPLATEHASH`, to the [HTLC claim transaction]). +/// +/// [HTLC claim transaction]: build_htlc_claim_transaction +pub(crate) fn offered_htlc_tapscript_leaves( + htlc: &HTLCOutputInCommitment, broadcaster_htlc_key: &HtlcKey, + countersignatory_htlc_key: &HtlcKey, countersignatory_payment_point: &PublicKey, +) -> (ScriptBuf, ScriptBuf) { + // In tapscript, `OP_CHECKSIG` expects 32-byte x-only (BIP-340) keys. + let broadcaster_xonly = broadcaster_htlc_key.to_public_key().x_only_public_key().0.serialize(); + let countersignatory_xonly = + countersignatory_htlc_key.to_public_key().x_only_public_key().0.serialize(); + + let htlc_timeout = Builder::new() + .push_slice(&broadcaster_xonly) + .push_opcode(opcodes::all::OP_CHECKSIGVERIFY) + .push_slice(&countersignatory_xonly) + .push_opcode(opcodes::all::OP_CHECKSIG) + .into_script(); + + let payment_hash160 = Ripemd160::hash(&htlc.payment_hash.0[..]).to_byte_array(); + let claim_tx = + build_htlc_claim_transaction(OutPoint::null(), htlc, countersignatory_payment_point); + let claim_tx_hash = get_template_hash(&claim_tx, 0); + let htlc_success = Builder::new() + .push_opcode(opcodes::all::OP_SIZE) + .push_int(32) + .push_opcode(opcodes::all::OP_EQUALVERIFY) + .push_opcode(opcodes::all::OP_HASH160) + .push_slice(&payment_hash160) + .push_opcode(opcodes::all::OP_EQUALVERIFY) + .push_slice(&claim_tx_hash) + .push_opcode(opcodes::all::OP_TEMPLATEHASH) + .push_opcode(opcodes::all::OP_EQUAL) + .into_script(); + + (htlc_timeout, htlc_success) +} + +/// The [`TaprootSpendInfo`] for an offered HTLC output when `option_htlcs_claim_tx` applies. +/// +/// The output is a P2TR with the revocation key as the internal (key-path) key — allowing the +/// counterparty to sweep a revoked commitment immediately — and a two-leaf script tree built from +/// [`offered_htlc_tapscript_leaves`]. +pub(crate) fn offered_htlc_taproot_spend_info( + htlc: &HTLCOutputInCommitment, revocation_key: &RevocationKey, broadcaster_htlc_key: &HtlcKey, + countersignatory_htlc_key: &HtlcKey, countersignatory_payment_point: &PublicKey, +) -> TaprootSpendInfo { + let (htlc_timeout, htlc_success) = offered_htlc_tapscript_leaves( + htlc, + broadcaster_htlc_key, + countersignatory_htlc_key, + countersignatory_payment_point, + ); + let internal_key = revocation_key.to_public_key().x_only_public_key().0; + // A verification-only context is sufficient to compute the taproot output key (an EC point + // tweak). The two-leaf tree is statically valid so none of these calls can fail. + let secp = Secp256k1::verification_only(); + let builder = TaprootBuilder::new() + .add_leaf(1, htlc_timeout) + .and_then(|builder| builder.add_leaf(1, htlc_success)) + .expect("Adding two taproot leaves at depth 1 is always valid"); + builder + .finalize(&secp, internal_key) + .unwrap_or_else(|_| unreachable!("a complete two-leaf taproot tree always finalizes")) +} + +/// The `scriptPubKey` of an offered HTLC output when `option_htlcs_claim_tx` applies (a P2TR). +pub(crate) fn get_offered_htlc_taproot_scriptpubkey( + htlc: &HTLCOutputInCommitment, revocation_key: &RevocationKey, broadcaster_htlc_key: &HtlcKey, + countersignatory_htlc_key: &HtlcKey, countersignatory_payment_point: &PublicKey, +) -> ScriptBuf { + let spend_info = offered_htlc_taproot_spend_info( + htlc, + revocation_key, + broadcaster_htlc_key, + countersignatory_htlc_key, + countersignatory_payment_point, + ); + ScriptBuf::new_p2tr_tweaked(spend_info.output_key()) +} + +/// The `scriptPubKey` placed in a commitment transaction for the given HTLC output, accounting for +/// the `option_htlcs_claim_tx` taproot offered-HTLC variant. +pub(crate) fn get_htlc_output_scriptpubkey( + htlc: &HTLCOutputInCommitment, channel_type_features: &ChannelTypeFeatures, + keys: &TxCreationKeys, countersignatory_payment_point: &PublicKey, +) -> ScriptBuf { + if htlc.offered && channel_type_features.supports_htlcs_claim_tx() { + get_offered_htlc_taproot_scriptpubkey( + htlc, + &keys.revocation_key, + &keys.broadcaster_htlc_key, + &keys.countersignatory_htlc_key, + countersignatory_payment_point, + ) + } else { + get_htlc_redeemscript(htlc, channel_type_features, keys).to_p2wsh() + } +} + +/// The witness spending an offered HTLC output via the preimage (`htlc_success`) path when +/// `option_htlcs_claim_tx` applies, used on the input of the [HTLC claim transaction]. +/// +/// [HTLC claim transaction]: build_htlc_claim_transaction +pub(crate) fn build_htlcs_claim_tx_witness( + preimage: &PaymentPreimage, spend_info: &TaprootSpendInfo, htlc_success_script: &Script, +) -> Witness { + let control_block = spend_info + .control_block(&(htlc_success_script.to_owned(), LeafVersion::TapScript)) + .expect("htlc_success leaf is part of the taproot tree"); + let mut witness = Witness::new(); + witness.push(preimage.0.to_vec()); + witness.push(htlc_success_script.to_bytes()); + witness.push(control_block.serialize()); + witness +} + /// Gets the redeemscript for a funding output from the two funding public keys. /// Note that the order of funding public keys does not matter. pub fn make_funding_redeemscript( @@ -1758,7 +1959,7 @@ impl CommitmentTransaction { let (obscured_commitment_transaction_number, txins) = Self::build_inputs(self.commitment_number, channel_parameters); // First rebuild the htlc outputs, note that `outputs` is now the same length as `self.nondust_htlcs` - let mut outputs = Self::build_htlc_outputs(keys, &self.nondust_htlcs, channel_parameters.channel_type_features()); + let mut outputs = Self::build_htlc_outputs(keys, &self.nondust_htlcs, channel_parameters); let nondust_htlcs_value_sum_sat = self.nondust_htlcs.iter().map(|htlc| htlc.to_bitcoin_amount()).sum(); @@ -1822,7 +2023,7 @@ impl CommitmentTransaction { ) -> Vec { // First build and sort the HTLC outputs. // Also sort the HTLC output data in `nondust_htlcs` in the same order. - let mut outputs = Self::build_sorted_htlc_outputs(keys, nondust_htlcs, channel_parameters.channel_type_features()); + let mut outputs = Self::build_sorted_htlc_outputs(keys, nondust_htlcs, channel_parameters); let nondust_htlcs_value_sum_sat = nondust_htlcs.iter().map(|htlc| htlc.to_bitcoin_amount()).sum(); @@ -1940,14 +2141,15 @@ impl CommitmentTransaction { } #[rustfmt::skip] - fn build_htlc_outputs(keys: &TxCreationKeys, nondust_htlcs: &Vec, channel_type: &ChannelTypeFeatures) -> Vec { + fn build_htlc_outputs(keys: &TxCreationKeys, nondust_htlcs: &Vec, channel_parameters: &DirectedChannelTransactionParameters) -> Vec { // Allocate memory for the 4 possible non-htlc outputs let mut txouts = Vec::with_capacity(nondust_htlcs.len() + 4); + let channel_type = channel_parameters.channel_type_features(); + let countersignatory_payment_point = &channel_parameters.countersignatory_pubkeys().payment_point; for htlc in nondust_htlcs { - let script = get_htlc_redeemscript(htlc, channel_type, keys); let txout = TxOut { - script_pubkey: script.to_p2wsh(), + script_pubkey: get_htlc_output_scriptpubkey(htlc, channel_type, keys, countersignatory_payment_point), value: htlc.to_bitcoin_amount(), }; txouts.push(txout); @@ -1960,10 +2162,10 @@ impl CommitmentTransaction { fn build_sorted_htlc_outputs( keys: &TxCreationKeys, nondust_htlcs: &mut Vec, - channel_type: &ChannelTypeFeatures + channel_parameters: &DirectedChannelTransactionParameters ) -> Vec { // Note that `txouts` has the same length as `nondust_htlcs` here - let mut txouts = Self::build_htlc_outputs(keys, nondust_htlcs, channel_type); + let mut txouts = Self::build_htlc_outputs(keys, nondust_htlcs, channel_parameters); // Sort the HTLC outputs by value, then by script pubkey, then by cltv expiration height. // @@ -2983,4 +3185,154 @@ mod tests { swap_htlcs!(small_htlc, big_htlc); } + + #[test] + fn test_htlcs_claim_tx() { + use super::{ + build_htlc_claim_transaction, get_offered_htlc_taproot_scriptpubkey, get_template_hash, + offered_htlc_tapscript_leaves, + }; + use super::{HtlcKey, RevocationKey}; + use bitcoin::opcodes::all::{OP_EQUAL, OP_TEMPLATEHASH}; + use bitcoin::transaction::Version; + + let secp_ctx = Secp256k1::new(); + let key = |b: u8| { + PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[b; 32]).unwrap()) + }; + let revocation_key = RevocationKey(key(1)); + let broadcaster_htlc_key = HtlcKey(key(2)); + let countersignatory_htlc_key = HtlcKey(key(3)); + let countersignatory_payment_point = key(4); + + let htlc = HTLCOutputInCommitment { + offered: true, + amount_msat: 1_234_567, + cltv_expiry: 500, + payment_hash: PaymentHash([0x42; 32]), + transaction_output_index: Some(0), + }; + + // The claim transaction is a zero-fee v3 transaction paying the full (rounded-down) HTLC + // value to a P2WPKH for the remote node's payment point. + let claim_tx = build_htlc_claim_transaction( + bitcoin::transaction::OutPoint::null(), + &htlc, + &countersignatory_payment_point, + ); + assert_eq!(claim_tx.version, Version::non_standard(3)); + assert_eq!(claim_tx.lock_time.to_consensus_u32(), 0); + assert_eq!(claim_tx.input.len(), 1); + assert_eq!(claim_tx.input[0].sequence.to_consensus_u32(), 0); + assert_eq!(claim_tx.output.len(), 1); + assert_eq!(claim_tx.output[0].value.to_sat(), htlc.amount_msat / 1000); + assert!(claim_tx.output[0].script_pubkey.is_p2wpkh()); + + // The template hash is deterministic and independent of the (un-committed) prevout, but + // depends on the committed output value. + let hash_a = get_template_hash(&claim_tx, 0); + let mut claim_tx_other_prevout = claim_tx.clone(); + claim_tx_other_prevout.input[0].previous_output = + bitcoin::transaction::OutPoint::new(Txid::from_slice(&[0xab; 32]).unwrap(), 7); + assert_eq!(hash_a, get_template_hash(&claim_tx_other_prevout, 0)); + + let mut bigger_htlc = htlc.clone(); + bigger_htlc.amount_msat += 1_000_000; + let bigger_claim_tx = build_htlc_claim_transaction( + bitcoin::transaction::OutPoint::null(), + &bigger_htlc, + &countersignatory_payment_point, + ); + assert_ne!(hash_a, get_template_hash(&bigger_claim_tx, 0)); + + // The `htlc_success` leaf commits to that template hash via `OP_TEMPLATEHASH`, and the + // `htlc_timeout` leaf is a 2-of-2 of the two HTLC keys. + let (htlc_timeout, htlc_success) = offered_htlc_tapscript_leaves( + &htlc, + &broadcaster_htlc_key, + &countersignatory_htlc_key, + &countersignatory_payment_point, + ); + let success_bytes = htlc_success.as_bytes(); + // The leaf ends with `<32-byte template hash> OP_TEMPLATEHASH OP_EQUAL`. + assert_eq!(*success_bytes.last().unwrap(), OP_EQUAL.to_u8()); + assert_eq!(success_bytes[success_bytes.len() - 2], OP_TEMPLATEHASH.to_u8()); + assert!(success_bytes.windows(hash_a.len()).any(|w| w == &hash_a[..])); + // `<32-byte key> OP_CHECKSIGVERIFY <32-byte key> OP_CHECKSIG`: + // (1 + 32) + 1 + (1 + 32) + 1 = 68 bytes. + assert_eq!(htlc_timeout.as_bytes().len(), 68); + assert_eq!( + *htlc_timeout.as_bytes().last().unwrap(), + bitcoin::opcodes::all::OP_CHECKSIG.to_u8() + ); + + // The commitment output is a P2TR. + let spk = get_offered_htlc_taproot_scriptpubkey( + &htlc, + &revocation_key, + &broadcaster_htlc_key, + &countersignatory_htlc_key, + &countersignatory_payment_point, + ); + assert!(spk.is_p2tr()); + } + + #[test] + fn test_htlcs_claim_tx_witness_classification() { + // The witness spending an `option_htlcs_claim_tx` offered HTLC via the preimage path is a + // P2TR script-path spend, which `HTLCClaim::from_witness` must recognize as an + // `OfferedPreimage` claim (so the monitor extracts the preimage and resolves the HTLC). + use super::{ + build_htlcs_claim_tx_witness, offered_htlc_taproot_spend_info, + offered_htlc_tapscript_leaves, HTLCClaim, HtlcKey, RevocationKey, + }; + use crate::types::payment::PaymentPreimage; + use bitcoin::Witness; + + let secp_ctx = Secp256k1::new(); + let key = |b: u8| { + PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[b; 32]).unwrap()) + }; + let revocation_key = RevocationKey(key(1)); + let broadcaster_htlc_key = HtlcKey(key(2)); + let countersignatory_htlc_key = HtlcKey(key(3)); + let countersignatory_payment_point = key(4); + + let htlc = HTLCOutputInCommitment { + offered: true, + amount_msat: 1_234_567, + cltv_expiry: 500, + payment_hash: PaymentHash([0x42; 32]), + transaction_output_index: Some(0), + }; + + let (_htlc_timeout, htlc_success) = offered_htlc_tapscript_leaves( + &htlc, + &broadcaster_htlc_key, + &countersignatory_htlc_key, + &countersignatory_payment_point, + ); + let spend_info = offered_htlc_taproot_spend_info( + &htlc, + &revocation_key, + &broadcaster_htlc_key, + &countersignatory_htlc_key, + &countersignatory_payment_point, + ); + let preimage = PaymentPreimage([0xcd; 32]); + let witness = build_htlcs_claim_tx_witness(&preimage, &spend_info, &htlc_success); + + // Recognized as an offered-preimage claim, with the preimage as the *first* witness element. + assert_eq!(witness.len(), 3); + assert!(HTLCClaim::from_witness(&witness) == Some(HTLCClaim::OfferedPreimage)); + assert_eq!(witness.nth(0).unwrap(), &preimage.0[..]); + + // A same-shaped witness whose leaf script does not commit via `OP_TEMPLATEHASH` is not + // misclassified as a templated claim. + let mut not_templated = Witness::new(); + not_templated.push(preimage.0.to_vec()); + not_templated.push(vec![0x51; htlc_success.as_bytes().len()]); // OP_TRUE-filled leaf + not_templated.push(witness.last().unwrap().to_vec()); + assert!(HTLCClaim::from_witness(¬_templated) != Some(HTLCClaim::OfferedPreimage)); + } } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d0072da226a..099570a7dbc 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -15196,6 +15196,10 @@ pub(super) fn channel_type_from_open_channel( if channel_type.requires_unknown_bits_from(&our_supported_features) { return Err(ChannelError::close("Channel Type contains unsupported features".to_owned())); } + // `option_htlcs_claim_tx` is only defined on top of `option_zero_fee_commitments`. + if channel_type.requires_htlcs_claim_tx() && !channel_type.requires_anchor_zero_fee_commitments() { + return Err(ChannelError::close("option_htlcs_claim_tx requires option_zero_fee_commitments".to_owned())); + } let announce_for_forwarding = if (common_fields.channel_flags & 1) == 1 { true } else { false }; if channel_type.requires_scid_privacy() && announce_for_forwarding { return Err(ChannelError::close("SCID Alias/Privacy Channel Type cannot be set on a public channel".to_owned())); @@ -15833,6 +15837,13 @@ pub(super) fn get_initial_channel_type( ret.set_anchor_zero_fee_commitments_required(); // `option_static_remote_key` is assumed by `option_zero_fee_commitments`. ret.clear_static_remote_key(); + // `option_htlcs_claim_tx` builds on top of `option_zero_fee_commitments`, so we can only + // negotiate it when the latter was selected. + if config.channel_handshake_config.negotiate_htlcs_claim_tx + && their_features.supports_htlcs_claim_tx() + { + ret.set_htlcs_claim_tx_required(); + } } else if config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx && their_features.supports_anchors_zero_fee_htlc_tx() { @@ -19582,6 +19593,389 @@ mod tests { }); } + // Test vectors from bolt03/htlcs-claim-tx-test.json + #[cfg(ldk_test_vectors)] + #[test] + fn htlcs_claim_tx_test_vectors() { + use crate::chain::transaction::OutPoint; + use crate::ln::chan_utils::{ + build_htlc_claim_transaction, build_htlcs_claim_tx_witness, get_template_hash, + offered_htlc_taproot_spend_info, offered_htlc_tapscript_leaves, + CounterpartyChannelTransactionParameters, HolderCommitmentTransaction, + }; + use crate::sign::ecdsa::EcdsaChannelSigner; + use crate::sync::Arc; + use crate::types::features::ChannelTypeFeatures; + use crate::util::config::UserConfig; + use crate::util::logger::Logger; + use crate::util::test_utils::{ + payment_hash_from_hex, preimage_from_hex, pubkey_from_hex, secret_from_hex, + }; + use bitcoin::consensus::encode::serialize; + use bitcoin::hash_types::Txid; + use bitcoin::hex::{DisplayHex, FromHex}; + use bitcoin::secp256k1::Secp256k1; + use core::str::FromStr; + + let feeest = TestFeeEstimator::new(250); + let logger: Arc = Arc::new(TestLogger::new()); + let secp_ctx = Secp256k1::new(); + + let alice_funding_privkey = + secret_from_hex("8f567cb6382507019349a47623902aa65d7a142ac85462eeb63dc11799ac2bb9"); + let alice_payment_basepoint_secret = + secret_from_hex("94f29d20a225ea2f7093331ba0f0f28a9382d8ed08e1fd121329925cd0c01b6d"); + let alice_delayed_payment_basepoint_secret = + secret_from_hex("e9d4e1935bf16e948d76ad007baf0646df023af38f41bcf2c8799336949d291e"); + let alice_htlc_basepoint_secret = + secret_from_hex("f699038ef4f95b6b16b22a5c04fcb3c508d68d02cd2f86cf197e0fac451681b0"); + let alice_revocation_base_secret = + secret_from_hex("1111111111111111111111111111111111111111111111111111111111111111"); + + let alice_signer = InMemorySigner::new( + alice_funding_privkey, + alice_revocation_base_secret, + alice_payment_basepoint_secret, + alice_payment_basepoint_secret, + true, + alice_delayed_payment_basepoint_secret, + alice_htlc_basepoint_secret, + [0xff; 32], + [0; 32], + [0; 32], + ); + let alice_keys_provider = Keys { signer: alice_signer.clone() }; + let alice_pubkeys = alice_signer.pubkeys(&secp_ctx); + + let bob_payment_basepoint_secret = + secret_from_hex("580bff39085f3a6ae8b1f32905e67366c522ea8f2418391145b2e98f1a7cb3f2"); + let bob_htlc_basepoint_secret = + secret_from_hex("32df9c4dd46ab6210e74e81e15282106f8db883f45674eabb3324166c6513062"); + let bob_funding_privkey = + secret_from_hex("4d22d96f0c0ccecffee4554d20ed43e51235917508ee292d281235bb7ebe0e3e"); + let bob_revocation_base_secret = + secret_from_hex("2222222222222222222222222222222222222222222222222222222222222222"); + let bob_delayed_payment_basepoint_secret = + secret_from_hex("2222222222222222222222222222222222222222222222222222222222222222"); + + let bob_signer = InMemorySigner::new( + bob_funding_privkey, + bob_revocation_base_secret, + bob_payment_basepoint_secret, + bob_payment_basepoint_secret, + true, + bob_delayed_payment_basepoint_secret, + bob_htlc_basepoint_secret, + [0xff; 32], + [0; 32], + [0; 32], + ); + + let mut bob_pubkeys = bob_signer.pubkeys(&secp_ctx); + bob_pubkeys.revocation_basepoint = RevocationBasepoint(pubkey_from_hex( + "026788d019ed90149cbc9aa5ff26dd7f1a6d3cd1bee8bf36cf7d8310fbd3606b14", + )); + + let bob_node_id = crate::util::test_utils::pubkey(2); + let mut config = UserConfig::default(); + config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + + let mut chan = OutboundV1Channel::<&Keys>::new( + &LowerBoundedFeeEstimator::new(&feeest), + &&alice_keys_provider, + &&alice_keys_provider, + bob_node_id, + &crate::ln::channelmanager::provided_init_features(&config), + 10_000_000, + 0, + 0, + &config, + 0, + 0, + None, + &*logger, + None, + ) + .unwrap(); + + chan.funding.counterparty_selected_channel_reserve_satoshis = Some(0); + chan.funding.holder_selected_channel_reserve_satoshis = 0; + + let funding_txid_str = "4b70a2ee47b3005a6316ff87055e94c6b3d433d0fd3b384c9ecf7813843c1eae"; + let funding_info = OutPoint { txid: Txid::from_str(funding_txid_str).unwrap(), index: 1 }; + + chan.funding.channel_transaction_parameters.holder_pubkeys = alice_pubkeys.clone(); + chan.funding.channel_transaction_parameters.counterparty_parameters = + Some(CounterpartyChannelTransactionParameters { + pubkeys: bob_pubkeys.clone(), + selected_contest_delay: 720, + }); + chan.funding.channel_transaction_parameters.funding_outpoint = Some(funding_info); + + let mut channel_type_features = ChannelTypeFeatures::anchors_zero_fee_commitments(); + channel_type_features.set_htlcs_claim_tx_required(); + chan.funding.channel_transaction_parameters.channel_type_features = + channel_type_features.clone(); + + let per_commitment_point = + pubkey_from_hex("0275d12130c276b4274358a328901f8fc47e6c72629102e4b46c9f27dd2c1dda98"); + + let bob_payment_point = + PublicKey::from_secret_key(&secp_ctx, &bob_payment_basepoint_secret); + + // Builds and signs a commitment tx as Alice (holder), signs the funding input as Bob + // (counterparty, via raw funding key), then asserts sigs and tx bytes. Returns the + // commitment txid, non-dust HTLCs, and the derived TxCreationKeys for further checks. + macro_rules! assert_commitment { + ( $counterparty_sig_hex: expr, $holder_sig_hex: expr, $tx_hex: expr ) => {{ + chan.funding.channel_transaction_parameters.channel_type_features = + channel_type_features.clone(); + let commitment_data = chan.context.build_commitment_transaction( + &chan.funding, + 0xffffffffffff - 42, + &per_commitment_point, + true, + false, + &logger, + ); + let commitment_tx = commitment_data.tx; + let trusted_tx = commitment_tx.trust(); + let unsigned_tx = trusted_tx.built_transaction(); + let redeemscript = chan.funding.get_funding_redeemscript(); + let commitment_txid = unsigned_tx.txid; + + let counterparty_sig = + Signature::from_der(&>::from_hex($counterparty_sig_hex).unwrap()) + .unwrap(); + let sighash = + unsigned_tx.get_sighash_all(&redeemscript, chan.funding.get_value_satoshis()); + assert!( + secp_ctx + .verify_ecdsa( + &sighash, + &counterparty_sig, + chan.funding.counterparty_funding_pubkey() + ) + .is_ok(), + "counterparty sig" + ); + + let holder_commitment_tx = HolderCommitmentTransaction::new( + commitment_tx.clone(), + counterparty_sig, + vec![], + &alice_pubkeys.funding_pubkey, + chan.funding.counterparty_funding_pubkey(), + ); + let holder_sig = alice_signer + .sign_holder_commitment( + &chan.funding.channel_transaction_parameters, + &holder_commitment_tx, + &secp_ctx, + ) + .unwrap(); + assert_eq!( + Signature::from_der(&>::from_hex($holder_sig_hex).unwrap()).unwrap(), + holder_sig, + "holder_sig" + ); + let tx = holder_commitment_tx.add_holder_sig(&redeemscript, holder_sig); + assert_eq!( + serialize(&tx)[..], + >::from_hex($tx_hex).unwrap()[..], + "commit_tx" + ); + + (commitment_txid, commitment_tx.nondust_htlcs().to_vec(), trusted_tx.keys().clone()) + }}; + } + + // Verifies the tapscript leaves, template hash, unsigned claim tx, and witnessed claim tx + // for a single offered HTLC output. + macro_rules! assert_htlc_claim_tx { + ( $commitment_txid: expr, $htlc: expr, $keys: expr, + $htlc_timeout_script_hex: expr, $htlc_success_script_hex: expr, + $template_hash_hex: expr, $htlc_claim_tx_hex: expr, + $htlc_claim_tx_with_witness_hex: expr, $preimage: expr ) => {{ + let (htlc_timeout, htlc_success) = offered_htlc_tapscript_leaves( + &$htlc, + &$keys.broadcaster_htlc_key, + &$keys.countersignatory_htlc_key, + &bob_payment_point, + ); + assert_eq!( + htlc_timeout.as_bytes().as_hex().to_string(), + $htlc_timeout_script_hex, + "htlc_timeout_script" + ); + assert_eq!( + htlc_success.as_bytes().as_hex().to_string(), + $htlc_success_script_hex, + "htlc_success_script" + ); + + let claim_tx = build_htlc_claim_transaction( + bitcoin::transaction::OutPoint { + txid: $commitment_txid, + vout: $htlc.transaction_output_index.unwrap(), + }, + &$htlc, + &bob_payment_point, + ); + let template_hash = get_template_hash(&claim_tx, 0); + assert_eq!(template_hash.as_hex().to_string(), $template_hash_hex, "template_hash"); + assert_eq!( + serialize(&claim_tx).as_hex().to_string(), + $htlc_claim_tx_hex, + "htlc_claim_tx" + ); + + let spend_info = offered_htlc_taproot_spend_info( + &$htlc, + &$keys.revocation_key, + &$keys.broadcaster_htlc_key, + &$keys.countersignatory_htlc_key, + &bob_payment_point, + ); + let mut claim_tx_with_witness = claim_tx; + claim_tx_with_witness.input[0].witness = + build_htlcs_claim_tx_witness(&$preimage, &spend_info, &htlc_success); + assert_eq!( + serialize(&claim_tx_with_witness).as_hex().to_string(), + $htlc_claim_tx_with_witness_hex, + "htlc_claim_tx_with_witness" + ); + }}; + } + + // Case 1: commitment transaction with a single outgoing HTLC above dust. + chan.context.holder_dust_limit_satoshis = 5000; + chan.funding.value_to_self_msat = 7925000000; + chan.context.pending_outbound_htlcs.extend( + [( + 5u64, + "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + 25000000u64, + )] + .map(|(htlc_id, hash_str, amount_msat)| OutboundHTLCOutput { + htlc_id, + amount_msat, + cltv_expiry: 920141, + payment_hash: payment_hash_from_hex(hash_str), + state: OutboundHTLCState::Committed, + source: HTLCSource::dummy(), + skimmed_fee_msat: None, + blinding_point: None, + send_timestamp: None, + hold_htlc: None, + accountable: false, + }), + ); + let (txid_1, htlcs_1, keys_1) = assert_commitment!( + "30450221008751c005679210d03024acbc9b7d48fcc4fdef4705ccc57e807d08b03ac7195002201d7dad4dd5175ab45989ec1ba73027b43d731168c527a001c3461010a5c9e094", + "304402207f0326a70ede1f74e48c139d917f0088d31761512247ef616e9ea892789ef8dc022062ef129647840952bb5a6512d56d56615b43fddd0372a40737bed33673548ccd", + "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800400000000000000000451024e73a861000000000000225120ac5217e5ac3039da7f479095f44d7058470dc146583c7412bcef9f4b6c820db878a91f0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea608b780000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a04004830450221008751c005679210d03024acbc9b7d48fcc4fdef4705ccc57e807d08b03ac7195002201d7dad4dd5175ab45989ec1ba73027b43d731168c527a001c3461010a5c9e0940147304402207f0326a70ede1f74e48c139d917f0088d31761512247ef616e9ea892789ef8dc022062ef129647840952bb5a6512d56d56615b43fddd0372a40737bed33673548ccd01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20" + ); + let htlc_1 = htlcs_1.into_iter().find(|h| h.offered).unwrap(); + assert_htlc_claim_tx!(txid_1, htlc_1, keys_1, + "20c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c6ad20d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0bac", + "82012088a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488204f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17fce87", + "4f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17f", + "030000000104430f5dfcde134fc4f7e5b6c345d8ea47d3bf2cad01fa3efa27a757296b165201000000000000000001a861000000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea00000000", + "0300000000010104430f5dfcde134fc4f7e5b6c345d8ea47d3bf2cad01fa3efa27a757296b165201000000000000000001a861000000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea03205591b96c0a6a03f51c27bfa658149260bd2fe5e2ce83130ce50d0229a3a947c53e82012088a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488204f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17fce8741c170308d9e9b4846a1b51accdae500e43383b11e906d0fab0a1e0f5ba203d3ccdcd23dd8e923d3880bee23a2b95d54c89c8a4d82a5903e96be5f0e99c262bb126300000000", + preimage_from_hex("5591b96c0a6a03f51c27bfa658149260bd2fe5e2ce83130ce50d0229a3a947c5") + ); + chan.context.pending_outbound_htlcs.clear(); + + // Case 2: millisatoshi truncation — 25000821 msat rounds down to 25000 sat in the claim tx. + chan.funding.value_to_self_msat = 7900000000; + chan.context.pending_outbound_htlcs.extend( + [( + 8u64, + "10b879729e8ddd44f2cfcf3cad6d62be535ca74e293c5ed4a59bd0dcbdad7ca1", + 25000821u64, + )] + .map(|(htlc_id, hash_str, amount_msat)| OutboundHTLCOutput { + htlc_id, + amount_msat, + cltv_expiry: 920141, + payment_hash: payment_hash_from_hex(hash_str), + state: OutboundHTLCState::Committed, + source: HTLCSource::dummy(), + skimmed_fee_msat: None, + blinding_point: None, + send_timestamp: None, + hold_htlc: None, + accountable: false, + }), + ); + let (txid_2, htlcs_2, keys_2) = assert_commitment!( + "30440220397404d3725e7dd53116c4a4bbfa33e0c2bf9cd141ef798273d5bbdd9cddcd6f02202169308d71911d2e44bda9c3da1c03faaf854b72b274bce9253e24ba108ccb23", + "304402207363d0a8b6e86b8c1a7b5e249d7c1ae1633433dc4cd741b760a9f37999c50bcd02200a14d835b77d2204554337320da5fd099462d24af836ac818bdf00e04967ebef", + "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800401000000000000000451024e73a8610000000000002251208b74ecccf0ec6463d625e6bf7f5e2161fc34e3110f894fe9189e6a9c5317d367200b200000000000160014f2123f1a4b67887f2e5f02eda73e6327010152eab729780000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a04004730440220397404d3725e7dd53116c4a4bbfa33e0c2bf9cd141ef798273d5bbdd9cddcd6f02202169308d71911d2e44bda9c3da1c03faaf854b72b274bce9253e24ba108ccb230147304402207363d0a8b6e86b8c1a7b5e249d7c1ae1633433dc4cd741b760a9f37999c50bcd02200a14d835b77d2204554337320da5fd099462d24af836ac818bdf00e04967ebef01475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20" + ); + let htlc_2 = htlcs_2.into_iter().find(|h| h.offered).unwrap(); + assert_htlc_claim_tx!(txid_2, htlc_2, keys_2, + "20c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c6ad20d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0bac", + "82012088a914504170790db95d43716b136806e6a0fdf06e39e488204f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17fce87", + "4f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17f", + "030000000100886fcf3999d07f5a886f193db5f1d004daa960235f303726accf3bb20bc04d01000000000000000001a861000000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea00000000", + "0300000000010100886fcf3999d07f5a886f193db5f1d004daa960235f303726accf3bb20bc04d01000000000000000001a861000000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea0320c916e086a4cd7d40f198708aefadd31149da628f820ca2fc213af10f7668501c3e82012088a914504170790db95d43716b136806e6a0fdf06e39e488204f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17fce8741c070308d9e9b4846a1b51accdae500e43383b11e906d0fab0a1e0f5ba203d3ccdcd23dd8e923d3880bee23a2b95d54c89c8a4d82a5903e96be5f0e99c262bb126300000000", + preimage_from_hex("c916e086a4cd7d40f198708aefadd31149da628f820ca2fc213af10f7668501c") + ); + chan.context.pending_outbound_htlcs.clear(); + + // Case 3: one outgoing (P2TR) + one incoming (P2WSH, unchanged) HTLC. + chan.funding.value_to_self_msat = 7000000000; + let htlc_in_preimage = + preimage_from_hex("108cd7067c8ed6f3734b7b67ec153cfa83c40755b75c65e414e934099e6993aa"); + chan.context.pending_inbound_htlcs.extend([1u64].map(|id| InboundHTLCOutput { + htlc_id: id, + amount_msat: 5000000, + cltv_expiry: 920150, + payment_hash: PaymentHash::from(htlc_in_preimage), + state: InboundHTLCState::Committed { update_add_htlc: dummy_inbound_update_add() }, + })); + chan.context.pending_outbound_htlcs.extend( + [( + 5u64, + "72c9386ba5a9d97b821d855930236d39c48dab5b1c2efe9ada44e2fbadcff983", + 25000000u64, + )] + .map(|(htlc_id, hash_str, amount_msat)| OutboundHTLCOutput { + htlc_id, + amount_msat, + cltv_expiry: 920141, + payment_hash: payment_hash_from_hex(hash_str), + state: OutboundHTLCState::Committed, + source: HTLCSource::dummy(), + skimmed_fee_msat: None, + blinding_point: None, + send_timestamp: None, + hold_htlc: None, + accountable: false, + }), + ); + let (txid_3, htlcs_3, keys_3) = assert_commitment!( + "304402205f818e459e0ec72dc00eba606eca389af4726c39d7f16a3570c64d26cbad665f02201333cefd527c771135fff156043f9429e2228c981f42cf54bcad3bf45805b599", + "30440220195dfbc8eaef5e18212c268f6d5d9a251463ef52334bd1f8f3ecdff263e7a42102200c6faa6b316d7bb16246cc30553fd53e2731e45f190fb84dedc0220f907f4fc3", + "03000000000101ae1e3c841378cf9e4c383bfdd033d4b3c6945e0587ff16635a00b347eea2704b0100000000340fef800500000000000000000451024e73881300000000000022002075254560bb02c207015847abfda36d3a1b882e78c3f04b08325aac21c53989dca861000000000000225120ac5217e5ac3039da7f479095f44d7058470dc146583c7412bcef9f4b6c820db838b32d0000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea186e6a0000000000220020f2d298ffcfd6d899a3abada37bfc6f42ce0b7b66f3e39e903e8419ac97dca75a040047304402205f818e459e0ec72dc00eba606eca389af4726c39d7f16a3570c64d26cbad665f02201333cefd527c771135fff156043f9429e2228c981f42cf54bcad3bf45805b599014730440220195dfbc8eaef5e18212c268f6d5d9a251463ef52334bd1f8f3ecdff263e7a42102200c6faa6b316d7bb16246cc30553fd53e2731e45f190fb84dedc0220f907f4fc301475221027eb9596a68740445fb151ff37d5422e7f65f2c497c90fda63e738eb606c15bd62103bbc16dc8851bece603322f06b3c8da329401b7be7e9fdd3f3090ad19aed0807052aec50fbb20" + ); + // Received HTLC stays as P2WSH (output index 1); offered HTLC is P2TR (output index 2). + let htlc_3 = htlcs_3.into_iter().find(|h| h.offered).unwrap(); + assert_htlc_claim_tx!(txid_3, htlc_3, keys_3, + "20c26117339025855b87deda5e138d438b2098881a5e6f81f72a60310faef473c6ad20d8507a026fb30bcd48ee9c765c7346470d0d397661d43dd2eb601f661ab92a0bac", + "82012088a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488204f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17fce87", + "4f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17f", + "03000000013d08505cc774eb835f4f655a12f1ae4f8338b4af2638c875057cee69d2e890b002000000000000000001a861000000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea00000000", + "030000000001013d08505cc774eb835f4f655a12f1ae4f8338b4af2638c875057cee69d2e890b002000000000000000001a861000000000000160014f2123f1a4b67887f2e5f02eda73e6327010152ea03205591b96c0a6a03f51c27bfa658149260bd2fe5e2ce83130ce50d0229a3a947c53e82012088a914488ed834d26f1a1dc5e3428e1e1a214f743e6a2488204f46273b2c989d2183e1379c396f5b575b859862c20938318418f31b6cedd17fce8741c170308d9e9b4846a1b51accdae500e43383b11e906d0fab0a1e0f5ba203d3ccdcd23dd8e923d3880bee23a2b95d54c89c8a4d82a5903e96be5f0e99c262bb126300000000", + preimage_from_hex("5591b96c0a6a03f51c27bfa658149260bd2fe5e2ce83130ce50d0229a3a947c5") + ); + chan.context.pending_inbound_htlcs.clear(); + chan.context.pending_outbound_htlcs.clear(); + let _ = htlc_in_preimage; + } + #[test] #[rustfmt::skip] fn test_per_commitment_secret_gen() { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c6002408f01..dd57cfb85dc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -17812,6 +17812,12 @@ pub fn provided_init_features(config: &UserConfig) -> InitFeatures { if config.channel_handshake_config.negotiate_anchor_zero_fee_commitments { features.set_anchor_zero_fee_commitments_optional(); + + // `option_htlcs_claim_tx` builds on top of `option_zero_fee_commitments`, so we only + // advertise it alongside the latter. + if config.channel_handshake_config.negotiate_htlcs_claim_tx { + features.set_htlcs_claim_tx_optional(); + } } if config.enable_htlc_hold { diff --git a/lightning/src/ln/zero_fee_commitment_tests.rs b/lightning/src/ln/zero_fee_commitment_tests.rs index 61c3e1063d0..640361d5a50 100644 --- a/lightning/src/ln/zero_fee_commitment_tests.rs +++ b/lightning/src/ln/zero_fee_commitment_tests.rs @@ -1,3 +1,4 @@ +use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ClosureReason, Event}; use crate::ln::chan_utils; use crate::ln::chan_utils::{ @@ -424,3 +425,97 @@ fn test_anchor_tx_too_big() { 1, ); } + +#[test] +fn test_counterparty_offered_htlc_claim_tx() { + // Exercise the full `option_htlcs_claim_tx` offered-HTLC on-chain resolution path: a routed + // HTLC is claimed by the recipient on-chain when the *counterparty's* commitment confirms, + // producing the templated, zero-fee version 3 claim transaction, which is fee-bumped via a CPFP + // child (a TRUC 1-parent-1-child package). + // + // Note: the test harness verifies spends with `bitcoinconsensus`, which does not implement + // `OP_TEMPLATEHASH` (in tapscript `0xce` is `OP_SUCCESS206`, so the script-path spend succeeds + // unconditionally). This therefore validates the taproot commitment and all of the LDK wiring, + // but not the template-hash digest itself. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut user_cfg = test_default_channel_config(); + user_cfg.channel_handshake_config.our_htlc_minimum_msat = 1; + user_cfg.channel_handshake_config.negotiate_anchor_zero_fee_commitments = true; + user_cfg.channel_handshake_config.negotiate_htlcs_claim_tx = true; + + let configs = [Some(user_cfg.clone()), Some(user_cfg)]; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &configs); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let coinbase_tx = provide_anchor_reserves(&nodes); + + const CHAN_CAPACITY: u64 = 10_000_000; + let (_, _, chan_id, _funding_tx) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + CHAN_CAPACITY, + (CHAN_CAPACITY / 2) * 1000, + ); + + // Route an HTLC from node 0 to node 1 and have node 1 learn the preimage, but don't deliver the + // resulting messages, so that node 0's commitment keeps the offered HTLC output. + const HTLC_AMT_MSAT: u64 = 1_000_000; + let (preimage, payment_hash, _, _) = route_payment(&nodes[0], &[&nodes[1]], HTLC_AMT_MSAT); + nodes[1].node.claim_funds(preimage); + check_added_monitors(&nodes[1], 1); + expect_payment_claimed!(nodes[1], payment_hash, HTLC_AMT_MSAT); + nodes[0].node.get_and_clear_pending_msg_events(); + nodes[1].node.get_and_clear_pending_msg_events(); + + let node_0_commit_tx = get_local_commitment_txn!(nodes[0], chan_id); + assert_eq!(node_0_commit_tx.len(), 1); + + // The counterparty's commitment confirms. + mine_transaction(&nodes[0], &node_0_commit_tx[0]); + mine_transaction(&nodes[1], &node_0_commit_tx[0]); + + check_closed_broadcast(&nodes[0], 1, true); + check_added_monitors(&nodes[0], 1); + let reason = ClosureReason::CommitmentTxConfirmed; + check_closed_event(&nodes[0], 1, reason, &[nodes[1].node.get_our_node_id()], CHAN_CAPACITY); + check_closed_broadcast(&nodes[1], 1, true); + check_added_monitors(&nodes[1], 1); + let reason = ClosureReason::CommitmentTxConfirmed; + check_closed_event(&nodes[1], 1, reason, &[nodes[0].node.get_our_node_id()], CHAN_CAPACITY); + + // Node 1 resolves the offered HTLC via the preimage path, yielding an HTLC-claim-tx CPFP event. + let mut events = nodes[1].chain_monitor.chain_monitor.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let bump_event = match events.pop().unwrap() { + Event::BumpTransaction(bump @ BumpTransactionEvent::HTLCsClaimTxResolution { .. }) => bump, + ev => panic!("Unexpected event: {:?}", ev), + }; + nodes[1].bump_tx_handler.handle_event(&bump_event); + + // The templated claim transaction and its fee-paying child are broadcast together as a package. + let broadcasts = nodes[1].tx_broadcaster.txn_broadcast(); + assert_eq!(broadcasts.len(), 2); + // The claim transaction is the fixed single-input, single-output version 3 transaction spending + // the offered HTLC output on the counterparty's commitment. We can't use `check_spends!` here + // because the claim transaction is intentionally zero-fee (it is fee-bumped by the child), but + // we still run consensus verification of the taproot script-path spend. + assert_eq!(broadcasts[0].version, bitcoin::transaction::Version::non_standard(3)); + assert_eq!(broadcasts[0].input.len(), 1); + assert_eq!(broadcasts[0].output.len(), 1); + let commit_txid = node_0_commit_tx[0].compute_txid(); + assert_eq!(broadcasts[0].input[0].previous_output.txid, commit_txid); + broadcasts[0] + .verify(|outpoint: &bitcoin::OutPoint| { + if outpoint.txid == commit_txid { + node_0_commit_tx[0].output.get(outpoint.vout as usize).cloned() + } else { + None + } + }) + .unwrap(); + // The child fee-bumps the claim transaction, spending its output plus a confirmed UTXO. + assert_eq!(broadcasts[1].version, bitcoin::transaction::Version::non_standard(3)); + check_spends!(broadcasts[1], broadcasts[0], coinbase_tx); +} diff --git a/lightning/src/sign/ecdsa.rs b/lightning/src/sign/ecdsa.rs index c0bd3759caa..9ef732fa789 100644 --- a/lightning/src/sign/ecdsa.rs +++ b/lightning/src/sign/ecdsa.rs @@ -1,6 +1,7 @@ //! Defines ECDSA-specific signer types. use bitcoin::transaction::Transaction; +use bitcoin::Witness; use bitcoin::secp256k1; use bitcoin::secp256k1::ecdsa::Signature; @@ -16,7 +17,7 @@ use crate::types::payment::PaymentPreimage; #[allow(unused_imports)] use crate::prelude::*; -use crate::sign::{ChannelSigner, HTLCDescriptor}; +use crate::sign::{ChannelSigner, HTLCDescriptor, StaticPaymentOutputDescriptor}; /// A trait to sign Lightning channel transactions as described in /// [BOLT 3](https://github.com/lightning/bolts/blob/master/03-transactions.md). @@ -227,6 +228,25 @@ pub trait EcdsaChannelSigner: ChannelSigner { &self, channel_parameters: &ChannelTransactionParameters, anchor_tx: &Transaction, input: usize, secp_ctx: &Secp256k1, ) -> Result; + /// Computes the full witness for the input of a fee-bumping child transaction that spends the + /// single P2WPKH output of an `option_htlcs_claim_tx` HTLC claim transaction, at index `input`. + /// + /// The HTLC claim transaction pays the HTLC value to our payment point and is zero-fee, so to be + /// relayed and confirmed it must be broadcast together with a fee-paying child as a TRUC + /// 1-parent-1-child package. `descriptor` describes the claim transaction output being spent by + /// the child here, which takes the form of a P2WPKH to our payment point. + /// + /// An `Err` can be returned to signal that the signer is unavailable/cannot produce a valid + /// signature and should be retried later. Once the signer is ready to provide a signature after + /// previously returning an `Err`, [`ChannelMonitor::signer_unblocked`] must be called on its + /// monitor or [`ChainMonitor::signer_unblocked`] called to attempt unblocking all monitors. + /// + /// [`ChannelMonitor::signer_unblocked`]: crate::chain::channelmonitor::ChannelMonitor::signer_unblocked + /// [`ChainMonitor::signer_unblocked`]: crate::chain::chainmonitor::ChainMonitor::signer_unblocked + fn sign_htlcs_claim_transaction_input( + &self, claim_child_tx: &Transaction, input: usize, + descriptor: &StaticPaymentOutputDescriptor, secp_ctx: &Secp256k1, + ) -> Result; /// Signs a channel announcement message with our funding key proving it comes from one of the /// channel participants. /// diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 3adc6380297..cdad925808a 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -1915,6 +1915,15 @@ impl EcdsaChannelSigner for InMemorySigner { Ok(sign_with_aux_rand(secp_ctx, &hash_to_message!(&sighash[..]), &funding_key, &self)) } + fn sign_htlcs_claim_transaction_input( + &self, claim_child_tx: &Transaction, input: usize, + descriptor: &StaticPaymentOutputDescriptor, secp_ctx: &Secp256k1, + ) -> Result { + // The claim transaction output is a plain P2WPKH to our payment point, exactly like a + // `to_remote`/static-payment output, so we can reuse the same signing path. + self.sign_counterparty_payment_input(claim_child_tx, input, descriptor, secp_ctx) + } + fn sign_channel_announcement_with_funding_key( &self, channel_parameters: &ChannelTransactionParameters, msg: &UnsignedChannelAnnouncement, secp_ctx: &Secp256k1, diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index fa01f8e21b4..e71e29eb5bb 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -241,6 +241,17 @@ pub struct ChannelHandshakeConfig { /// [`Event::OpenChannelRequest`]: crate::events::Event::OpenChannelRequest pub negotiate_anchor_zero_fee_commitments: bool, + /// Set to enable the experimental `option_htlcs_claim_tx` channel type, which commits (via + /// `OP_TEMPLATEHASH`) to a v3 claim transaction in the preimage spend path of offered HTLC + /// outputs, closing the last on-chain pinning gap in Lightning. + /// + /// This option requires [`Self::negotiate_anchor_zero_fee_commitments`] to be set as it builds + /// on top of `option_zero_fee_commitments`; if the counterparty does not understand it (or we + /// fall back to a different channel type) it is silently dropped. + /// + /// Default value: `false` + pub negotiate_htlcs_claim_tx: bool, + /// The maximum number of HTLCs in-flight from our counterparty towards us at the same time. /// /// Increasing the value can help improve liquidity and stability in @@ -272,6 +283,7 @@ impl Default for ChannelHandshakeConfig { their_channel_reserve_proportional_millionths: 10_000, negotiate_anchors_zero_fee_htlc_tx: true, negotiate_anchor_zero_fee_commitments: false, + negotiate_htlcs_claim_tx: false, our_max_accepted_htlcs: 50, } } @@ -304,6 +316,7 @@ impl Readable for ChannelHandshakeConfig { their_channel_reserve_proportional_millionths: Readable::read(reader)?, negotiate_anchors_zero_fee_htlc_tx: Readable::read(reader)?, negotiate_anchor_zero_fee_commitments: Readable::read(reader)?, + negotiate_htlcs_claim_tx: Readable::read(reader)?, our_max_accepted_htlcs: Readable::read(reader)?, }) } diff --git a/lightning/src/util/dyn_signer.rs b/lightning/src/util/dyn_signer.rs index 5da284d25a4..99464ae2642 100644 --- a/lightning/src/util/dyn_signer.rs +++ b/lightning/src/util/dyn_signer.rs @@ -17,11 +17,12 @@ use crate::sign::{ChannelSigner, ReceiveAuthKey}; use crate::sign::{EntropySource, HTLCDescriptor, OutputSpender, PhantomKeysManager}; use crate::sign::{ NodeSigner, PeerStorageKey, Recipient, SignerProvider, SpendableOutputDescriptor, + StaticPaymentOutputDescriptor, }; use bitcoin; use bitcoin::absolute::LockTime; use bitcoin::secp256k1::All; -use bitcoin::{secp256k1, ScriptBuf, Transaction, TxOut, Txid}; +use bitcoin::{secp256k1, ScriptBuf, Transaction, TxOut, Txid, Witness}; use lightning_invoice::RawBolt11Invoice; use secp256k1::ecdsa::RecoverableSignature; use secp256k1::{ecdh::SharedSecret, ecdsa::Signature, PublicKey, Scalar, Secp256k1, SecretKey}; @@ -90,7 +91,10 @@ delegate!(DynSigner, EcdsaChannelSigner, inner, fn sign_holder_htlc_transaction(, htlc_tx: &Transaction, input: usize, htlc_descriptor: &HTLCDescriptor, secp_ctx: &Secp256k1) -> Result, fn sign_splice_shared_input(, channel_parameters: &ChannelTransactionParameters, - tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1) -> Result + tx: &Transaction, input_index: usize, secp_ctx: &Secp256k1) -> Result, + fn sign_htlcs_claim_transaction_input(, claim_child_tx: &Transaction, input: usize, + descriptor: &StaticPaymentOutputDescriptor, + secp_ctx: &Secp256k1) -> Result ); delegate!(DynSigner, ChannelSigner, diff --git a/lightning/src/util/test_channel_signer.rs b/lightning/src/util/test_channel_signer.rs index 668bbebad05..fb87c09874e 100644 --- a/lightning/src/util/test_channel_signer.rs +++ b/lightning/src/util/test_channel_signer.rs @@ -16,6 +16,7 @@ use crate::ln::channel_keys::HtlcKey; use crate::ln::msgs; use crate::sign::ecdsa::EcdsaChannelSigner; use crate::sign::ChannelSigner; +use crate::sign::StaticPaymentOutputDescriptor; use crate::types::payment::PaymentPreimage; #[allow(unused_imports)] @@ -32,7 +33,7 @@ use bitcoin::hashes::Hash; use bitcoin::sighash; use bitcoin::sighash::EcdsaSighashType; use bitcoin::transaction::Transaction; -use bitcoin::Txid; +use bitcoin::{Txid, Witness}; use crate::sign::HTLCDescriptor; use crate::util::dyn_signer::DynSigner; @@ -499,6 +500,13 @@ impl EcdsaChannelSigner for TestChannelSigner { self.inner.sign_holder_keyed_anchor_input(chan_params, anchor_tx, input, secp_ctx) } + fn sign_htlcs_claim_transaction_input( + &self, claim_child_tx: &Transaction, input: usize, + descriptor: &StaticPaymentOutputDescriptor, secp_ctx: &Secp256k1, + ) -> Result { + self.inner.sign_htlcs_claim_transaction_input(claim_child_tx, input, descriptor, secp_ctx) + } + fn sign_channel_announcement_with_funding_key( &self, channel_parameters: &ChannelTransactionParameters, msg: &msgs::UnsignedChannelAnnouncement, secp_ctx: &Secp256k1, diff --git a/pending_changelog/option-htlcs-claim-tx.txt b/pending_changelog/option-htlcs-claim-tx.txt new file mode 100644 index 00000000000..d3042650a82 --- /dev/null +++ b/pending_changelog/option-htlcs-claim-tx.txt @@ -0,0 +1,28 @@ +# API Updates + + * Added experimental support for the `option_htlcs_claim_tx` channel type (feature bits + 110/111), which builds on `option_zero_fee_commitments` to close the last on-chain pinning + gap. Offered HTLC outputs become P2TR outputs whose preimage (`htlc_success`) spend path + commits, via `OP_TEMPLATEHASH` (BIP-446/448), to a fixed v3 "HTLC claim transaction". Enable it + by setting `ChannelHandshakeConfig::negotiate_htlcs_claim_tx` (requires + `negotiate_anchor_zero_fee_commitments`). + + This feature is experimental and incomplete: + * `OP_TEMPLATEHASH` is not in a released `bitcoin`, so this relies on a `[patch.crates-io]` + entry pointing at a fork that adds the BIP-446/448 opcode and the BIP-446 `template_hash` + digest on top of the `bitcoin` patch release we otherwise use. Both the opcode and the + underlying soft fork are still subject to change. + * On-chain resolution of an offered HTLC via the preimage path now broadcasts the fixed, + templated v3 claim transaction (a single zero-fee P2WPKH output for the full HTLC value) as an + untractable, non-aggregated package, rather than a custom malleable sweep. Because the claim + transaction is zero-fee, it is surfaced via the new + `BumpTransactionEvent::HTLCsClaimTxResolution` so the user can attach a fee-paying child + spending its output (a TRUC 1-parent-1-child package), analogous to how zero-fee commitment + transactions are fee-bumped. The new `EcdsaChannelSigner::sign_htlcs_claim_transaction_input` + signs the child's input spending the claim transaction output. The default + `BumpTransactionEventHandler` constructs, signs, and broadcasts the package automatically. Do + not enable on mainnet. + * The counterparty's `ChannelMonitor` recognizes the templated claim transaction's P2TR + script-path witness (`[preimage, htlc_success_script, control_block]`) as an offered-HTLC + preimage claim via `HTLCClaim::from_witness`, extracting the preimage from it so the offering + (or a forwarding) node can resolve the corresponding HTLC on-chain.