From e33ac3eb738100d8b5bc20c75ab00c5d28bc7f08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 7 Oct 2026 18:42:45 -0400 Subject: [PATCH 1/4] Fail a channel funded by a coinbase transaction, and fund none from one --- lightning/src/ln/blake2b_tests.rs | 85 ++++++++++++++++++++++++++++++ lightning/src/ln/channel.rs | 13 +++++ lightning/src/ln/channelmanager.rs | 8 +++ 3 files changed, 106 insertions(+) diff --git a/lightning/src/ln/blake2b_tests.rs b/lightning/src/ln/blake2b_tests.rs index 3e9d295b787..5f96d25e42b 100644 --- a/lightning/src/ln/blake2b_tests.rs +++ b/lightning/src/ln/blake2b_tests.rs @@ -282,3 +282,88 @@ fn test_pay_bolt11_invoice_between_blake2b_nodes() { }; claim_payment(&nodes[0], &[&nodes[1]], preimage); } + +#[test] +fn test_fundee_fails_coinbase_funded_channel() { + // Under BLAKE2b proof of work a spend of a coinbase output does not relay for far longer than + // its maturity, so the fundee fails such a channel once the funding confirms (BOLT 2). + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = unified_config(true); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + nodes[0].node.create_channel(node_b_id, 100000, 10001, 42, None, None).unwrap(); + let open_channel = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + handle_and_accept_open_channel(&nodes[1], node_a_id, &open_channel); + let accept_channel = get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel); + + let (channel_id, tx, _) = + create_coinbase_funding_transaction(&nodes[0], &node_b_id, 100000, 42); + // Our own checked funding path refuses a coinbase (see `test_no_coinbase_funding`), so fund + // through the manual path, as a funder that does not follow these rules could. + let outpoint = crate::chain::transaction::OutPoint { txid: tx.compute_txid(), index: 0 }; + nodes[0] + .node + .unsafe_manual_funding_transaction_generated(channel_id, node_b_id, outpoint) + .unwrap(); + let funding_created = get_event_msg!(nodes[0], MessageSendEvent::SendFundingCreated, node_b_id); + nodes[1].node.handle_funding_created(node_a_id, &funding_created); + check_added_monitors(&nodes[1], 1); + expect_channel_pending_event(&nodes[1], &node_a_id); + let funding_signed = get_event_msg!(nodes[1], MessageSendEvent::SendFundingSigned, node_a_id); + nodes[0].node.handle_funding_signed(node_b_id, &funding_signed); + check_added_monitors(&nodes[0], 1); + // The manual funding path also tells us when the funding is safe to broadcast. + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.iter().any(|ev| matches!(ev, Event::ChannelPending { .. }))); + assert!(events.iter().any(|ev| matches!(ev, Event::FundingTxBroadcastSafe { .. }))); + + confirm_transaction_at(&nodes[1], &tx, 1); + let err = "Funding transaction is a coinbase, which cannot be spent until it matures"; + check_closed_event( + &nodes[1], + 1, + ClosureReason::ProcessingError { err: err.to_owned() }, + &[node_a_id], + 100000, + ); + check_closed_broadcast(&nodes[1], 1, true); + check_added_monitors(&nodes[1], 1); + assert!(nodes[1].node.list_channels().is_empty()); + + // The rule is the fundee's: the funder keeps the channel when the coinbase confirms. + confirm_transaction_at(&nodes[0], &tx, 1); + assert_eq!(nodes[0].node.list_channels().len(), 1); + assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); +} + +#[test] +fn test_no_coinbase_funding() { + // A node following BLAKE2b does not fund a channel from a coinbase transaction. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let config = unified_config(true); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + nodes[0].node.create_channel(node_b_id, 100000, 10001, 42, None, None).unwrap(); + let open_channel = get_event_msg!(nodes[0], MessageSendEvent::SendOpenChannel, node_b_id); + handle_and_accept_open_channel(&nodes[1], node_a_id, &open_channel); + let accept_channel = get_event_msg!(nodes[1], MessageSendEvent::SendAcceptChannel, node_a_id); + nodes[0].node.handle_accept_channel(node_b_id, &accept_channel); + + let (channel_id, tx, _) = + create_coinbase_funding_transaction(&nodes[0], &node_b_id, 100000, 42); + match nodes[0].node.funding_transaction_generated(channel_id, node_b_id, tx) { + Err(APIError::APIMisuseError { err }) => assert!(err.contains("coinbase")), + res => panic!("Wrong result {:?}", res), + } + let _ = nodes[0].node.get_and_clear_pending_events(); + let _ = nodes[0].node.get_and_clear_pending_msg_events(); +} diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 875e9af1f42..701caa9313a 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -12558,6 +12558,19 @@ where )?; if is_funding_tx_confirmed { + // A spend of a coinbase output does not relay for far longer than the coinbase + // maturity under BLAKE2b proof of work, so no commitment transaction of a + // coinbase-funded channel could be broadcast while its HTLCs expire, so the fundee + // fails it (BOLT 2). A 0-conf channel, already in use on trust, is exempt. + if tx.is_coinbase() + && user_config.follow_blake2b + && !self.funding.is_outbound() + && !self.funding.get_channel_type().supports_zero_conf() + && self.context.minimum_depth.unwrap_or(0) > 0 + { + let err = "Funding transaction is a coinbase, which cannot be spent until it matures"; + return Err(ClosureReason::ProcessingError { err: err.to_owned() }); + } // If this is a coinbase transaction and not a 0-conf channel // we should update our min_depth to 100 to handle coinbase maturity if tx.is_coinbase() && diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f6e4f493bc2..61b422be0fa 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -6673,6 +6673,14 @@ impl< } } + // A spend of a coinbase output does not relay until long after it matures under + // BLAKE2b proof of work, so no commitment transaction of the channel could be broadcast. + if self.config.read().unwrap().follow_blake2b && funding_transaction.is_coinbase() { + result = result.and(Err(APIError::APIMisuseError { + err: "A coinbase transaction cannot fund a channel".to_owned(), + })); + } + if funding_transaction.output.len() > u16::max_value() as usize { result = result.and(Err(APIError::APIMisuseError { err: "Transaction had more than 2^16 outputs, which is not supported" From b5540a7798009422f598b6c43da026f1bdcdb11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 7 Oct 2026 19:28:12 -0400 Subject: [PATCH 2/4] Do not splice a channel without option_unified_sigs, and tx_abort one --- lightning/src/ln/blake2b_tests.rs | 88 ++++++++++++++++++++++++++++++ lightning/src/ln/channel.rs | 21 ++++++- lightning/src/ln/channelmanager.rs | 37 ++++++++++++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/blake2b_tests.rs b/lightning/src/ln/blake2b_tests.rs index 5f96d25e42b..6d3faf4a662 100644 --- a/lightning/src/ln/blake2b_tests.rs +++ b/lightning/src/ln/blake2b_tests.rs @@ -341,6 +341,94 @@ fn test_fundee_fails_coinbase_funded_channel() { assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } +#[test] +fn test_no_splice_of_channel_without_unified_sigs() { + // A node following BLAKE2b neither splices a channel without option_unified_sigs nor accepts + // such a splice, answering it with tx_abort (BOLT 2). + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let legacy = test_default_channel_config(); + let node_chanmgrs = + create_node_chanmgrs(2, &node_cfgs, &[Some(legacy.clone()), Some(legacy.clone())]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + + let (_, _, channel_id, _) = create_announced_chan_between_nodes(&nodes, 0, 1); + assert!(!nodes[1].node.list_channels()[0] + .channel_type + .as_ref() + .unwrap() + .supports_unified_sigs()); + // A contribution prepared before following BLAKE2b. + provide_utxo_reserves(&nodes, 1, bitcoin::Amount::from_sat(100_000)); + let template = nodes[1].node.splice_channel(&channel_id, &node_a_id).unwrap(); + let feerate = bitcoin::FeeRate::from_sat_per_kwu( + crate::chain::chaininterface::FEERATE_FLOOR_SATS_PER_KW as u64, + ); + let wallet = crate::util::wallet_utils::WalletSync::new( + std::sync::Arc::clone(&nodes[1].wallet_source), + nodes[1].logger, + ); + let contribution = template + .splice_in_sync(bitcoin::Amount::from_sat(50_000), feerate, bitcoin::FeeRate::MAX, &wallet) + .unwrap(); + + let mut follows = legacy; + follows.follow_blake2b = true; + nodes[1].node.set_current_config(follows); + + match nodes[1].node.splice_channel(&channel_id, &node_a_id) { + Err(APIError::APIMisuseError { err }) => assert!(err.contains("option_unified_sigs")), + res => panic!("Wrong result {:?}", res.err()), + } + match nodes[1].node.funding_contributed(&channel_id, &node_a_id, contribution, None) { + Err(crate::ln::channelmanager::SpliceContributionError::NegotiationFailed { + reason: crate::events::NegotiationFailureReason::ContributionInvalid, + }) => {}, + res => panic!("Wrong result {:?}", res), + } + let events = nodes[1].node.get_and_clear_pending_events(); + assert!(events.iter().any(|ev| matches!(ev, Event::DiscardFunding { .. }))); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + crate::ln::splicing_tests::initiate_splice_in( + &nodes[0], + &nodes[1], + channel_id, + bitcoin::Amount::from_sat(50_000), + ); + let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_b_id); + nodes[1].node.handle_stfu(node_a_id, &stfu); + let stfu = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_a_id); + nodes[0].node.handle_stfu(node_b_id, &stfu); + let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_b_id); + nodes[1].node.handle_splice_init(node_a_id, &splice_init); + let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_a_id); + assert!(String::from_utf8_lossy(&tx_abort.data).contains("option_unified_sigs")); + nodes[0].node.handle_tx_abort(node_b_id, &tx_abort); + if let Some(MessageSendEvent::SendTxAbort { msg, .. }) = nodes[0] + .node + .get_and_clear_pending_msg_events() + .into_iter() + .find(|ev| matches!(ev, MessageSendEvent::SendTxAbort { .. })) + { + nodes[1].node.handle_tx_abort(node_a_id, &msg); + } + // The initiator releases its wallet inputs and learns why the splice failed; nothing else is + // left in flight on either side. + let events = nodes[0].node.get_and_clear_pending_events(); + assert!(events.iter().any(|ev| matches!(ev, Event::DiscardFunding { .. }))); + assert!(events.iter().any(|ev| matches!(ev, Event::SpliceNegotiationFailed { .. }))); + assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_events().is_empty()); + assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty()); + + // The channel is still usable in both directions. + send_payment(&nodes[0], &[&nodes[1]], 10_000_000); + send_payment(&nodes[1], &[&nodes[0]], 1_000_000); +} + #[test] fn test_no_coinbase_funding() { // A node following BLAKE2b does not fund a channel from a coinbase transaction. diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 701caa9313a..c13376e28bb 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -13977,9 +13977,10 @@ where pub(crate) fn splice_init( &mut self, msg: &msgs::SpliceInit, entropy_source: &ES, holder_node_id: &PublicKey, - min_funding_satoshis: u64, logger: &L, + min_funding_satoshis: u64, follow_blake2b: bool, logger: &L, ) -> Result { self.validate_splice_init(msg).map_err(|e| self.quiescent_negotiation_err(e))?; + self.refuse_splice_without_unified_sigs(follow_blake2b)?; let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64); let (queued_net_value, holder_balance) = self @@ -14152,11 +14153,13 @@ where pub(crate) fn tx_init_rbf( &mut self, msg: &msgs::TxInitRbf, entropy_source: &ES, holder_node_id: &PublicKey, - fee_estimator: &LowerBoundedFeeEstimator, min_funding_satoshis: u64, logger: &L, + fee_estimator: &LowerBoundedFeeEstimator, min_funding_satoshis: u64, + follow_blake2b: bool, logger: &L, ) -> Result { let (holder_pubkeys, counterparty_funding_pubkey) = self .validate_tx_init_rbf(msg, fee_estimator) .map_err(|e| self.quiescent_negotiation_err(e))?; + self.refuse_splice_without_unified_sigs(follow_blake2b)?; let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64); let (queued_net_value, holder_balance) = self @@ -15493,6 +15496,20 @@ where was_quiescent } + /// Following BLAKE2b, a splice of a channel without option_unified_sigs is answered with + /// `tx_abort` (BOLT 2). Called once the splice has been validated, so we are quiescent. + fn refuse_splice_without_unified_sigs( + &mut self, follow_blake2b: bool, + ) -> Result<(), InteractiveTxMsgError> { + if follow_blake2b && !self.funding.get_channel_type().supports_unified_sigs() { + let err = ChannelError::Abort(AbortReason::InternalError( + "Splicing a channel without option_unified_sigs is not allowed", + )); + return Err(self.quiescent_negotiation_err(err)); + } + Ok(()) + } + fn quiescent_negotiation_err(&mut self, err: ChannelError) -> InteractiveTxMsgError { if matches!(err, ChannelError::Abort(_)) { debug_assert!(self.context.channel_state.is_quiescent()); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 61b422be0fa..cf5a0c854bf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5013,7 +5013,7 @@ impl< /// /// Initiating a splice requires that the channel counterparty supports splicing. Any /// channel (no matter the type) can be spliced, as long as the counterparty is currently - /// connected. + /// connected, except that with [`UserConfig::follow_blake2b`] no channel can be spliced yet. /// /// # Return Value /// @@ -5059,6 +5059,18 @@ impl< match peer_state.channel_by_id.entry(*channel_id) { hash_map::Entry::Occupied(chan_phase_entry) => { if let Some(chan) = chan_phase_entry.get().as_funded() { + // Following BLAKE2b, a channel without option_unified_sigs is not spliced: the + // splice would sign the previous funding output without the opt-in (BOLT 2). + if self.config.read().unwrap().follow_blake2b + && !chan.funding.get_channel_type().supports_unified_sigs() + { + return Err(APIError::APIMisuseError { + err: format!( + "Channel {} does not use option_unified_sigs, so it cannot be spliced", + channel_id + ), + }); + } chan.splice_channel() } else { Err(APIError::ChannelUnavailable { @@ -7012,6 +7024,15 @@ impl< match peer_state.channel_by_id.get_mut(channel_id) { Some(channel) => match channel.as_funded_mut() { Some(chan) => { + // See `splice_channel`: no splice of a channel without option_unified_sigs. + if self.config.read().unwrap().follow_blake2b + && !chan.funding.get_channel_type().supports_unified_sigs() + { + result = Err(SpliceContributionError::NegotiationFailed { + reason: events::NegotiationFailureReason::ContributionInvalid, + }); + return push_discard_funding(contribution); + } let locktime = bitcoin::absolute::LockTime::from_consensus( locktime.unwrap_or_else(|| self.current_best_block().height), ); @@ -13718,7 +13739,16 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ )) }, hash_map::Entry::Occupied(mut chan_entry) => { - if self.config.read().unwrap().reject_inbound_splices { + // Following BLAKE2b, a splice of a channel without option_unified_sigs is answered with + // `tx_abort` by `splice_init` (BOLT 2), even when inbound splices are not accepted. + let follow_blake2b = self.config.read().unwrap().follow_blake2b; + let refuse_without_unified_sigs = follow_blake2b + && chan_entry.get().as_funded().is_some_and(|chan| { + !chan.funding.get_channel_type().supports_unified_sigs() + }); + if self.config.read().unwrap().reject_inbound_splices + && !refuse_without_unified_sigs + { let err = ChannelError::WarnAndDisconnect( "Inbound channel splices are currently not allowed".to_owned(), ); @@ -13732,6 +13762,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self.entropy_source, &self.get_our_node_id(), self.config.read().unwrap().channel_handshake_limits.min_funding_satoshis, + follow_blake2b, &self.logger, ) { Ok(splice_ack_msg) => { @@ -13784,12 +13815,14 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ hash_map::Entry::Occupied(mut chan_entry) => { if let Some(ref mut funded_channel) = chan_entry.get_mut().as_funded_mut() { let user_channel_id = funded_channel.context.get_user_id(); + let follow_blake2b = self.config.read().unwrap().follow_blake2b; match funded_channel.tx_init_rbf( msg, &self.entropy_source, &self.get_our_node_id(), &self.fee_estimator, self.config.read().unwrap().channel_handshake_limits.min_funding_satoshis, + follow_blake2b, &self.logger, ) { Ok(tx_ack_rbf_msg) => { From c6115ab42813a05281884a52b9edab3dad560382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 7 Oct 2026 19:28:13 -0400 Subject: [PATCH 3/4] Do not announce a channel funded before the BLAKE2b activation --- lightning/src/ln/channel.rs | 6 +++++- lightning/src/routing/gossip.rs | 12 ++++-------- lightning/src/util/scid_utils.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index c13376e28bb..a6fa7ee1312 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -89,7 +89,7 @@ use crate::util::config::{ }; 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::scid_utils::{block_from_scid, scid_from_parts, scid_predates_blake2b}; use crate::util::ser::{Iterable, Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use crate::util::wallet_utils::{ConfirmedUtxo, Input}; use crate::{impl_readable_for_vec, impl_writeable_for_vec}; @@ -12951,6 +12951,10 @@ where let short_channel_id = self.funding.get_short_channel_id() .ok_or(ChannelError::Ignore("Cannot get a ChannelAnnouncement if the channel has not been confirmed yet".to_owned()))?; + // A channel funded before the BLAKE2b activation is not announced (BOLT 7). + if scid_predates_blake2b(chain_hash, short_channel_id) { + return Err(ChannelError::Ignore("Channel was funded before the BLAKE2b activation".to_owned())); + } let node_id = NodeId::from_pubkey(&node_signer.get_node_id(Recipient::Node) .map_err(|_| ChannelError::Ignore("Failed to retrieve own public key".to_owned()))?); let counterparty_node_id = NodeId::from_pubkey(&self.context.get_counterparty_node_id()); diff --git a/lightning/src/routing/gossip.rs b/lightning/src/routing/gossip.rs index 0874492ee4f..9eaa5a205c7 100644 --- a/lightning/src/routing/gossip.rs +++ b/lightning/src/routing/gossip.rs @@ -41,7 +41,9 @@ use crate::util::indexed_map::{ Entry as IndexedMapEntry, IndexedMap, OccupiedEntry as IndexedMapOccupiedEntry, }; use crate::util::logger::{Level, Logger}; -use crate::util::scid_utils::{block_from_scid, scid_from_parts, MAX_SCID_BLOCK}; +use crate::util::scid_utils::{ + block_from_scid, scid_from_parts, scid_predates_blake2b, MAX_SCID_BLOCK, +}; use crate::util::ser::{MaybeReadable, Readable, ReadableArgs, RequiredWrapper, Writeable, Writer}; use crate::util::wakers::Future; @@ -2159,13 +2161,7 @@ impl NetworkGraph { /// A funding output from before the BLAKE2b activation may have been spent where we cannot see /// it, so a channel funded there is not added to the graph from P2P gossip. fn predates_blake2b(&self, short_channel_id: u64) -> bool { - // The fuzz seeds replay mainnet gossip for scids in the first blocks. - if cfg!(fuzzing) { - return false; - } - Network::from_chain_hash(self.chain_hash) - .and_then(|network| bitcoin::consensus::Params::new(network).blake2b_height) - .map_or(false, |height| block_from_scid(short_channel_id) < height) + scid_predates_blake2b(self.chain_hash, short_channel_id) } /// Update channel information from a received announcement. diff --git a/lightning/src/util/scid_utils.rs b/lightning/src/util/scid_utils.rs index 342c062d7f1..a4eba7993a1 100644 --- a/lightning/src/util/scid_utils.rs +++ b/lightning/src/util/scid_utils.rs @@ -37,6 +37,20 @@ pub fn block_from_scid(short_channel_id: u64) -> u32 { return (short_channel_id >> 40) as u32; } +/// Whether `short_channel_id` refers to a funding output from before the BLAKE2b activation of the +/// chain, which may have been spent where a node following BLAKE2b cannot see it (BOLT 7). +pub(crate) fn scid_predates_blake2b( + chain_hash: bitcoin::constants::ChainHash, short_channel_id: u64, +) -> bool { + // The fuzz seeds use mainnet scids from the first blocks. + if cfg!(fuzzing) { + return false; + } + bitcoin::Network::from_chain_hash(chain_hash) + .and_then(|network| bitcoin::consensus::Params::new(network).blake2b_height) + .is_some_and(|height| block_from_scid(short_channel_id) < height) +} + /// Extracts the tx index (bytes [2..4]) from the `short_channel_id` pub fn tx_index_from_scid(short_channel_id: u64) -> u32 { return ((short_channel_id >> 16) & MAX_SCID_TX_INDEX) as u32; @@ -299,6 +313,22 @@ pub(crate) mod fake_scid { mod tests { use super::*; + #[test] + fn test_scid_predates_blake2b() { + use bitcoin::constants::ChainHash; + use bitcoin::Network; + + for (network, activation) in [(Network::Bitcoin, 961_640), (Network::Testnet4, 150_308)] { + let chain_hash = ChainHash::using_genesis_block(network); + let below = scid_from_parts(activation - 1, 1, 0).unwrap(); + let at = scid_from_parts(activation, 1, 0).unwrap(); + assert!(scid_predates_blake2b(chain_hash, below)); + assert!(!scid_predates_blake2b(chain_hash, at)); + } + let regtest = ChainHash::using_genesis_block(Network::Regtest); + assert!(!scid_predates_blake2b(regtest, scid_from_parts(1, 1, 0).unwrap())); + } + #[test] fn test_block_from_scid() { assert_eq!(block_from_scid(0x000000_000000_0000), 0); From cc8612819759bb4e039b5e8e1c46432e69960ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 7 Oct 2026 19:28:13 -0400 Subject: [PATCH 4/4] router: apply a one-hop blinded path's CLTV expiry delta --- lightning/src/routing/router.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 6cbbf69bfbf..bc94ce4ee9b 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -1734,7 +1734,8 @@ pub struct OneHopBlindedPathCandidate<'a> { /// Information about the blinded path including the fee, HTLC amount limits, and /// cryptographic material required to build an HTLC terminating with the given path. /// - /// Note that the [`BlindedPayInfo`] is ignored here. + /// Note that the fees in the [`BlindedPayInfo`] are ignored here, while its CLTV expiry delta + /// is used. /// /// This is not exported to bindings users as lifetimes are not expressible in most languages. /// @@ -1861,7 +1862,9 @@ impl<'a> CandidateRouteHop<'a> { CandidateRouteHop::PublicHop(hop) => hop.info.direction().cltv_expiry_delta as u32, CandidateRouteHop::PrivateHop(hop) => hop.hint.cltv_expiry_delta as u32, CandidateRouteHop::Blinded(hop) => hop.hint.payinfo.cltv_expiry_delta as u32, - CandidateRouteHop::OneHopBlinded(_) => 0, + // The recipient folds its `min_final_cltv_expiry_delta` into this delta (BOLT 4), so + // it applies even when the introduction node is the recipient. + CandidateRouteHop::OneHopBlinded(hop) => hop.hint.payinfo.cltv_expiry_delta as u32, } } @@ -8491,7 +8494,7 @@ mod tests { assert_eq!(final_hop.cltv_expiry_delta, blinded_payinfo.cltv_expiry_delta as u32); } else { assert_eq!(final_hop.fee_msat, 0); - assert_eq!(final_hop.cltv_expiry_delta, 0); + assert_eq!(final_hop.cltv_expiry_delta, blinded_payinfo.cltv_expiry_delta as u32); } }