From e245c0aa3a87166d141c17e514e4812daf147be6 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 02:22:59 +0000 Subject: [PATCH 01/14] Fix 0.2 CHANGELOG to note that offers will break on downgrade It turns out we also switched the key we use to authenticate offers *created* in the 0.2 upgrade and as a result downgrading to 0.2 will break any offers created on 0.2. This wasn't intentional but it doesn't really seem worth fixing at this point, so just document it. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e83ef2a14d..12f926cacad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -201,6 +201,8 @@ generated for inclusion in BOLT 12 `Offer`s will no longer be accepted. As most blinded message paths are ephemeral, this should only invalidate issued BOLT 12 `Refund`s in practice (#3917). + * Blinded message paths included in BOLT 12 `Offer`s generated by LDK 0.2 will + not be accepted by prior versions of LDK after downgrade (#3917). * Once a channel has been spliced, LDK can no longer be downgraded. `UserConfig::reject_inbound_splices` can be set to block inbound ones (#4150) * Downgrading after setting `UserConfig::enable_htlc_hold` is not supported From 19d7a31764b1f48d203792d53179b3e29ad4f7de Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 12:10:32 +0000 Subject: [PATCH 02/14] Add an `ExpandedKey` key for phantom blinded path authentication In the coming commits we'll add support for building a blinded path which can be received to any one of several nodes in a "phantom" configuration (terminology we retain from BOLT 11 though there are no longer any phantom nodes in the paths). Here we adda new key in `ExpandedKey` which we can use to authenticate blinded paths as coming from a phantom node participant. --- lightning/src/crypto/utils.rs | 15 ++++++++++----- lightning/src/ln/inbound_payment.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lightning/src/crypto/utils.rs b/lightning/src/crypto/utils.rs index 1570b3a0b2f..88911b0baf8 100644 --- a/lightning/src/crypto/utils.rs +++ b/lightning/src/crypto/utils.rs @@ -22,7 +22,7 @@ macro_rules! hkdf_extract_expand { let (k1, k2, _) = hkdf_extract_expand!($salt, $ikm); (k1, k2) }}; - ($salt: expr, $ikm: expr, 6) => {{ + ($salt: expr, $ikm: expr, 7) => {{ let (k1, k2, prk) = hkdf_extract_expand!($salt, $ikm); let mut hmac = HmacEngine::::new(&prk[..]); @@ -45,7 +45,12 @@ macro_rules! hkdf_extract_expand { hmac.input(&[6; 1]); let k6 = Hmac::from_engine(hmac).to_byte_array(); - (k1, k2, k3, k4, k5, k6) + let mut hmac = HmacEngine::::new(&prk[..]); + hmac.input(&k6); + hmac.input(&[7; 1]); + let k7 = Hmac::from_engine(hmac).to_byte_array(); + + (k1, k2, k3, k4, k5, k6, k7) }}; } @@ -53,10 +58,10 @@ pub fn hkdf_extract_expand_twice(salt: &[u8], ikm: &[u8]) -> ([u8; 32], [u8; 32] hkdf_extract_expand!(salt, ikm, 2) } -pub fn hkdf_extract_expand_6x( +pub fn hkdf_extract_expand_7x( salt: &[u8], ikm: &[u8], -) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { - hkdf_extract_expand!(salt, ikm, 6) +) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { + hkdf_extract_expand!(salt, ikm, 7) } #[inline] diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 51f8b7bfce9..62e002a44b8 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -15,7 +15,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; use crate::crypto::chacha20::ChaCha20; -use crate::crypto::utils::hkdf_extract_expand_6x; +use crate::crypto::utils::hkdf_extract_expand_7x; use crate::ln::msgs; use crate::ln::msgs::MAX_VALUE_MSAT; use crate::offers::nonce::Nonce; @@ -56,6 +56,10 @@ pub struct ExpandedKey { /// The key used to authenticate spontaneous payments' metadata as previously registered with LDK /// for inclusion in a blinded path. spontaneous_pmt_key: [u8; 32], + /// The key used to authenticate phantom-node-shared blinded paths as generated by us. Note + /// that this is not used for blinded paths which are not expected to be shared across nodes + /// participating in a "phantom node". + pub(crate) phantom_node_blinded_path_key: [u8; 32], } impl ExpandedKey { @@ -70,7 +74,8 @@ impl ExpandedKey { offers_base_key, offers_encryption_key, spontaneous_pmt_key, - ) = hkdf_extract_expand_6x(b"LDK Inbound Payment Key Expansion", &key_material); + phantom_node_blinded_path_key, + ) = hkdf_extract_expand_7x(b"LDK Inbound Payment Key Expansion", &key_material); Self { metadata_key, ldk_pmt_hash_key, @@ -78,6 +83,7 @@ impl ExpandedKey { offers_base_key, offers_encryption_key, spontaneous_pmt_key, + phantom_node_blinded_path_key, } } From 14a2f6f77d52b291b4145ed2f31e63798336b182 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 12:42:26 +0000 Subject: [PATCH 03/14] f sp --- lightning/src/ln/inbound_payment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 62e002a44b8..d70a20eaf44 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -57,7 +57,7 @@ pub struct ExpandedKey { /// for inclusion in a blinded path. spontaneous_pmt_key: [u8; 32], /// The key used to authenticate phantom-node-shared blinded paths as generated by us. Note - /// that this is not used for blinded paths which are not expected to be shared across nodes + /// that this is not used for blinded paths that are not expected to be shared across nodes /// participating in a "phantom node". pub(crate) phantom_node_blinded_path_key: [u8; 32], } From 038109eae1f07ef3f15018e58d5fdb5aeaf2f687 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 12:11:28 +0000 Subject: [PATCH 04/14] Accept blinded paths built by a phantom node participant In the next commit we'll add support for building a BOLT 12 offer which can be paid to any one of a number of participant nodes. Here we add support for validating blinded paths as coming from one of the participating nodes by deriving a new key as a part of the `ExpandedKey`. We keep this separate from the existing `ReceiveAuthKey` which is node-specific to ensure that we only allow this key to be used for blinded payment paths and contexts in `invoice_request` messages. This ensures that normal onion messages are still tied to specific nodes. Note that we will not yet use the blinded payment path phantom support which requires additional future work. However, allowing them to be authenticated in a phantom configuration should allow for compatibility across versions once the building logic lands. --- lightning/src/blinded_path/payment.rs | 12 ++-- lightning/src/crypto/streams.rs | 59 +++++++++++--------- lightning/src/ln/blinded_payment_tests.rs | 4 +- lightning/src/ln/msgs.rs | 46 +++++++++------ lightning/src/onion_message/messenger.rs | 26 +++++---- lightning/src/onion_message/packet.rs | 68 +++++++++++++++-------- lightning/src/util/test_utils.rs | 2 +- 7 files changed, 133 insertions(+), 84 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 27292bacf4d..da3b93e0216 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -14,7 +14,7 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use crate::blinded_path::utils::{self, BlindedPathWithPadding}; use crate::blinded_path::{BlindedHop, BlindedPath, IntroductionNode, NodeIdLookUp}; -use crate::crypto::streams::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::ChaChaTriPolyReadAdapter; use crate::io; use crate::io::Cursor; use crate::ln::channel_state::CounterpartyForwardingInfo; @@ -268,15 +268,17 @@ impl BlindedPaymentPath { node_signer.ecdh(Recipient::Node, &self.inner_path.blinding_point, None)?; let rho = onion_utils::gen_rho_from_shared_secret(&control_tlvs_ss.secret_bytes()); let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; + let read_arg = (rho, receive_auth_key.0, phantom_auth_key); + let encrypted_control_tlvs = &self.inner_path.blinded_hops.get(0).ok_or(())?.encrypted_payload; let mut s = Cursor::new(encrypted_control_tlvs); let mut reader = FixedLengthReader::new(&mut s, encrypted_control_tlvs.len() as u64); - let ChaChaDualPolyReadAdapter { readable, used_aad } = - ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0)) - .map_err(|_| ())?; + let ChaChaTriPolyReadAdapter { readable, used_aad_a, used_aad_b } = + ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; - match (&readable, used_aad) { + match (&readable, used_aad_a || used_aad_b) { (BlindedPaymentTlvs::Forward(_), false) | (BlindedPaymentTlvs::Dummy(_), true) | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index c406e933bc9..6bda4078b49 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -58,7 +58,7 @@ impl<'a, T: Writeable> Writeable for ChaChaPolyWriteAdapter<'a, T> { } /// Encrypts the provided plaintext with the given key using ChaCha20Poly1305 in the modified -/// with-AAD form used in [`ChaChaDualPolyReadAdapter`]. +/// with-AAD form used in [`ChaChaTriPolyReadAdapter`]. pub(crate) fn chachapoly_encrypt_with_swapped_aad( mut plaintext: Vec, key: [u8; 32], aad: [u8; 32], ) -> Vec { @@ -87,31 +87,34 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( /// Enables the use of the serialization macros for objects that need to be simultaneously decrypted /// and deserialized. This allows us to avoid an intermediate Vec allocation. /// -/// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags twice, once using the given -/// key and once with the given 32-byte AAD appended after the encrypted stream, accepting either -/// being correct as sufficient. +/// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags thrice, once using the given +/// key and twice with each of the two given 32-byte AADs appended after the encrypted stream, +/// accepting any being correct as sufficient. /// -/// Note that we do *not* use the provided AAD as the standard ChaCha20Poly1305 AAD as that would +/// Note that we do *not* use the provided AADs as the standard ChaCha20Poly1305 AAD as that would /// require placing it first and prevent us from avoiding redundant Poly1305 rounds. Instead, the /// ChaCha20Poly1305 MAC check is tweaked to move the AAD to *after* the the contents being /// checked, effectively treating the contents as the AAD for the AAD-containing MAC but behaving /// like classic ChaCha20Poly1305 for the non-AAD-containing MAC. -pub(crate) struct ChaChaDualPolyReadAdapter { +pub(crate) struct ChaChaTriPolyReadAdapter { pub readable: R, - pub used_aad: bool, + pub used_aad_a: bool, + pub used_aad_b: bool, } -impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyReadAdapter { +impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> + for ChaChaTriPolyReadAdapter +{ // Simultaneously read and decrypt an object from a LengthLimitedRead storing it in // Self::readable. LengthLimitedRead must be used instead of std::io::Read because we need the // total length to separate out the tag at the end. fn read( - r: &mut R, params: ([u8; 32], [u8; 32]), + r: &mut R, params: ([u8; 32], [u8; 32], [u8; 32]), ) -> Result { if r.remaining_bytes() < 16 { return Err(DecodeError::InvalidValue); } - let (key, aad) = params; + let (key, aad_a, aad_b) = params; let mut chacha = ChaCha20::new(&key[..], &[0; 12]); let mut mac_key = [0u8; 64]; @@ -125,7 +128,7 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea let decrypted_len = r.remaining_bytes() - 16; let s = FixedLengthReader::new(r, decrypted_len); let mut chacha_stream = - ChaChaDualPolyReader { chacha: &mut chacha, poly: &mut mac, read_len: 0, read: s }; + ChaChaTriPolyReader { chacha: &mut chacha, poly: &mut mac, read_len: 0, read: s }; let readable: T = Readable::read(&mut chacha_stream)?; while chacha_stream.read.bytes_remain() { @@ -142,14 +145,18 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea mac.input(&[0; 16][0..16 - (read_len % 16)]); } - let mut mac_aad = mac; + let mut mac_aad_a = mac; + let mut mac_aad_b = mac; - mac_aad.input(&aad[..]); + mac_aad_a.input(&aad_a[..]); + mac_aad_b.input(&aad_b[..]); // Note that we don't need to pad the AAD since its a multiple of 16 bytes // For the AAD-containing MAC, swap the AAD and the read data, effectively. - mac_aad.input(&(read_len as u64).to_le_bytes()); - mac_aad.input(&32u64.to_le_bytes()); + mac_aad_a.input(&(read_len as u64).to_le_bytes()); + mac_aad_b.input(&(read_len as u64).to_le_bytes()); + mac_aad_a.input(&32u64.to_le_bytes()); + mac_aad_b.input(&32u64.to_le_bytes()); // For the non-AAD-containing MAC, leave the data and AAD where they belong. mac.input(&0u64.to_le_bytes()); @@ -158,23 +165,25 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea let mut tag = [0 as u8; 16]; r.read_exact(&mut tag)?; if fixed_time_eq(&mac.result(), &tag) { - Ok(Self { readable, used_aad: false }) - } else if fixed_time_eq(&mac_aad.result(), &tag) { - Ok(Self { readable, used_aad: true }) + Ok(Self { readable, used_aad_a: false, used_aad_b: false }) + } else if fixed_time_eq(&mac_aad_a.result(), &tag) { + Ok(Self { readable, used_aad_a: true, used_aad_b: false }) + } else if fixed_time_eq(&mac_aad_b.result(), &tag) { + Ok(Self { readable, used_aad_a: false, used_aad_b: true }) } else { return Err(DecodeError::InvalidValue); } } } -struct ChaChaDualPolyReader<'a, R: Read> { +struct ChaChaTriPolyReader<'a, R: Read> { chacha: &'a mut ChaCha20, poly: &'a mut Poly1305, read_len: usize, pub read: R, } -impl<'a, R: Read> Read for ChaChaDualPolyReader<'a, R> { +impl<'a, R: Read> Read for ChaChaTriPolyReader<'a, R> { // Decrypts bytes from Self::read into `dest`. // After all reads complete, the caller must compare the expected tag with // the result of `Poly1305::result()`. @@ -349,15 +358,15 @@ mod tests { } #[test] - fn short_read_chacha_dual_read_adapter() { - // Previously, if we attempted to read from a ChaChaDualPolyReadAdapter but the object + fn short_read_chacha_tri_read_adapter() { + // Previously, if we attempted to read from a ChaChaTriPolyReadAdapter but the object // being read is shorter than the available buffer while the buffer passed to - // ChaChaDualPolyReadAdapter itself always thinks it has room, we'd end up + // ChaChaTriPolyReadAdapter itself always thinks it has room, we'd end up // infinite-looping as we didn't handle `Read::read`'s 0 return values at EOF. let mut stream = &[0; 1024][..]; let mut too_long_stream = FixedLengthReader::new(&mut stream, 2048); - let keys = ([42; 32], [99; 32]); - let res = super::ChaChaDualPolyReadAdapter::::read(&mut too_long_stream, keys); + let keys = ([42; 32], [98; 32], [99; 32]); + let res = super::ChaChaTriPolyReadAdapter::::read(&mut too_long_stream, keys); match res { Ok(_) => panic!(), Err(e) => assert_eq!(e, DecodeError::ShortRead), diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d78b9dfa4f2..d9f3374d481 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1696,7 +1696,7 @@ fn route_blinding_spec_test_vector() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { ExpandedKey::new([42; 32]) } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, @@ -2011,7 +2011,7 @@ fn test_trampoline_inbound_payment_decoding() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { ExpandedKey::new([42; 32]) } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 67f7807a487..00d15d121f7 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -56,7 +56,7 @@ use core::str::FromStr; #[cfg(feature = "std")] use std::net::SocketAddr; -use crate::crypto::streams::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::ChaChaTriPolyReadAdapter; use crate::util::base32; use crate::util::logger; use crate::util::ser::{ @@ -3924,10 +3924,13 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo .map_err(|_| DecodeError::InvalidValue)?; let rho = onion_utils::gen_rho_from_shared_secret(&enc_tlvs_ss.secret_bytes()); let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; + let read_args = (rho, receive_auth_key.0, phantom_auth_key); + let mut s = Cursor::new(&enc_tlvs); let mut reader = FixedLengthReader::new(&mut s, enc_tlvs.len() as u64); - match ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0))? { - ChaChaDualPolyReadAdapter { + match ChaChaTriPolyReadAdapter::read(&mut reader, read_args)? { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, @@ -3936,13 +3939,14 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo features, next_blinding_override, }), - used_aad, + used_aad_a, + used_aad_b, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad_a || used_aad_b { return Err(DecodeError::InvalidValue); } @@ -3955,16 +3959,17 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }), - used_aad, + used_aad_a, + used_aad_b, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || !used_aad + || (!used_aad_a && !used_aad_b) { return Err(DecodeError::InvalidValue); } @@ -3974,11 +3979,12 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo intro_node_blinding_point, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), - used_aad, + used_aad_a, + used_aad_b, } => { - if !used_aad { + if !used_aad_a && !used_aad_b { return Err(DecodeError::InvalidValue); } @@ -4041,6 +4047,7 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline fn read(r: &mut R, args: (Option, NS)) -> Result { let (update_add_blinding_point, node_signer) = args; let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; let mut amt = None; let mut cltv_value = None; @@ -4094,8 +4101,9 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline let rho = onion_utils::gen_rho_from_shared_secret(&enc_tlvs_ss.secret_bytes()); let mut s = Cursor::new(&enc_tlvs); let mut reader = FixedLengthReader::new(&mut s, enc_tlvs.len() as u64); - match ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0))? { - ChaChaDualPolyReadAdapter { + let read_args = (rho, receive_auth_key.0, phantom_auth_key); + match ChaChaTriPolyReadAdapter::read(&mut reader, read_args)? { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Forward(TrampolineForwardTlvs { next_trampoline, @@ -4104,13 +4112,14 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline features, next_blinding_override, }), - used_aad, + used_aad_a, + used_aad_b, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad_a || used_aad_b { return Err(DecodeError::InvalidValue); } @@ -4123,11 +4132,12 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Receive(receive_tlvs), - used_aad, + used_aad_a, + used_aad_b, } => { - if !used_aad { + if !used_aad_a && !used_aad_b { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index e688c020ac6..f94eb7877f5 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1168,12 +1168,13 @@ pub fn peel_onion_message match (message, context) { (ParsedOnionMessageContents::Offers(msg), Some(MessageContext::Offers(ctx))) => { match ctx { OffersContext::InvoiceRequest { .. } => { - // Note: We introduced the `control_tlvs_authenticated` check in LDK v0.2 + // Note: We introduced the `control_tlvs_from_*` check in LDK v0.2 // to simplify and standardize onion message authentication. // To continue supporting offers created before v0.2, we allow // unauthenticated control TLVs for these messages, as they can be // verified using the legacy method. }, _ => { - if !control_tlvs_authenticated { + // In any other offers context, we only allow message authenticated as + // coming from our local, node, not any other phantom participant. + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated offers onion message"); return Err(()); } @@ -1248,14 +1252,14 @@ pub fn peel_onion_message { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated async payments onion message"); return Err(()); } Ok(PeeledOnion::AsyncPayments(msg, ctx, reply_path)) }, (ParsedOnionMessageContents::Custom(msg), Some(MessageContext::Custom(ctx))) => { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated custom onion message"); return Err(()); } @@ -1268,7 +1272,7 @@ pub fn peel_onion_message { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated DNS resolver onion message"); return Err(()); } @@ -2504,7 +2508,8 @@ fn packet_payloads_and_keys< control_tlvs, reply_path: reply_path.take(), message, - control_tlvs_authenticated: false, + control_tlvs_from_local_node: false, + control_tlvs_from_phantom_participant: false, }, prev_control_tlvs_ss.unwrap(), )); @@ -2514,7 +2519,8 @@ fn packet_payloads_and_keys< control_tlvs: ReceiveControlTlvs::Unblinded(ReceiveTlvs { context: None }), reply_path: reply_path.take(), message, - control_tlvs_authenticated: false, + control_tlvs_from_local_node: false, + control_tlvs_from_phantom_participant: false, }, prev_control_tlvs_ss.unwrap(), )); diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index 2e0ccaf3a3e..b7779e87f01 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -19,7 +19,8 @@ use super::offers::OffersMessage; use crate::blinded_path::message::{ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs, }; -use crate::crypto::streams::{ChaChaDualPolyReadAdapter, ChaChaPolyWriteAdapter}; +use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter}; +use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::sign::ReceiveAuthKey; @@ -121,9 +122,16 @@ pub(super) enum Payload { }, /// This payload is for the final hop. Receive { - /// The [`ReceiveControlTlvs`] were authenticated with the additional key which was + /// The [`ReceiveControlTlvs`] were authenticated with the [`ReceiveAuthKey`] which was /// provided to [`ReadableArgs::read`]. - control_tlvs_authenticated: bool, + control_tlvs_from_local_node: bool, + /// The [`ReceiveControlTlvs`] were authenticated with the + /// [`ExpandedKey::phantom_node_blinded_path_key`] which was provided to + /// [`ReadableArgs::read`]. + /// Note that this is currently never actually read, but exists to signal the type of + /// authentication we can do. + #[allow(dead_code)] + control_tlvs_from_phantom_participant: bool, control_tlvs: ReceiveControlTlvs, reply_path: Option, message: T, @@ -233,7 +241,8 @@ impl Writeable for (Payload, [u8; 32]) { control_tlvs: ReceiveControlTlvs::Blinded(encrypted_bytes), reply_path, message, - control_tlvs_authenticated: _, + control_tlvs_from_local_node: _, + control_tlvs_from_phantom_participant: _, } => { _encode_varint_length_prefixed_tlv!(w, { (2, reply_path, option), @@ -253,7 +262,8 @@ impl Writeable for (Payload, [u8; 32]) { control_tlvs: ReceiveControlTlvs::Unblinded(control_tlvs), reply_path, message, - control_tlvs_authenticated: _, + control_tlvs_from_local_node: _, + control_tlvs_from_phantom_participant: _, } => { let write_adapter = ChaChaPolyWriteAdapter::new(self.1, &control_tlvs); _encode_varint_length_prefixed_tlv!(w, { @@ -269,24 +279,27 @@ impl Writeable for (Payload, [u8; 32]) { // Uses the provided secret to simultaneously decode and decrypt the control TLVs and data TLV. impl - ReadableArgs<(SharedSecret, &H, ReceiveAuthKey, &L)> + ReadableArgs<(SharedSecret, &H, ReceiveAuthKey, &ExpandedKey, &L)> for Payload::CustomMessage>> { fn read( - r: &mut R, args: (SharedSecret, &H, ReceiveAuthKey, &L), + r: &mut R, args: (SharedSecret, &H, ReceiveAuthKey, &ExpandedKey, &L), ) -> Result { - let (encrypted_tlvs_ss, handler, receive_tlvs_key, logger) = args; + let (encrypted_tlvs_ss, handler, receive_tlvs_key, expanded_key, logger) = args; let v: BigSize = Readable::read(r)?; let mut rd = FixedLengthReader::new(r, v.0); let mut reply_path: Option = None; - let mut read_adapter: Option> = None; + let mut read_adapter: Option> = None; let rho = onion_utils::gen_rho_from_shared_secret(&encrypted_tlvs_ss.secret_bytes()); + let read_adapter_args = + (rho, receive_tlvs_key.0, expanded_key.phantom_node_blinded_path_key); let mut message_type: Option = None; let mut message = None; + decode_tlv_stream_with_custom_tlv_decode!(&mut rd, { (2, reply_path, option), - (4, read_adapter, (option: LengthReadableArgs, (rho, receive_tlvs_key.0))), + (4, read_adapter, (option: LengthReadableArgs, read_adapter_args)), }, |msg_type, msg_reader| { if msg_type < 64 { return Ok(false) } // Don't allow reading more than one data TLV from an onion message. @@ -322,23 +335,32 @@ impl match read_adapter { None => return Err(DecodeError::InvalidValue), - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Forward(tlvs), used_aad }) => { - if used_aad || message_type.is_some() { + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Forward(tlvs), + used_aad_a, + used_aad_b, + }) => { + if used_aad_a || used_aad_b || message_type.is_some() { return Err(DecodeError::InvalidValue); } Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs))) }, - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Dummy, used_aad }) => { - Ok(Payload::Dummy { control_tlvs_authenticated: used_aad }) - }, - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), used_aad }) => { - Ok(Payload::Receive { - control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), - reply_path, - message: message.ok_or(DecodeError::InvalidValue)?, - control_tlvs_authenticated: used_aad, - }) - }, + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Dummy, + used_aad_a, + used_aad_b, + }) => Ok(Payload::Dummy { control_tlvs_authenticated: used_aad_a || used_aad_b }), + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Receive(tlvs), + used_aad_a, + used_aad_b, + }) => Ok(Payload::Receive { + control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), + reply_path, + message: message.ok_or(DecodeError::InvalidValue)?, + control_tlvs_from_local_node: used_aad_a, + control_tlvs_from_phantom_participant: used_aad_b, + }), } } } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 34f5d5fe36e..a12b113b293 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1772,7 +1772,7 @@ impl TestNodeSigner { impl NodeSigner for TestNodeSigner { fn get_expanded_key(&self) -> ExpandedKey { - unreachable!() + ExpandedKey::new([42; 32]) } fn get_peer_storage_key(&self) -> PeerStorageKey { From bd5088e6f580cd26913d75c74ac21866992ccae7 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 21:06:43 +0000 Subject: [PATCH 05/14] f use an enum --- lightning/src/blinded_path/payment.rs | 12 ++++++------ lightning/src/crypto/streams.rs | 21 ++++++++++++++++----- lightning/src/ln/msgs.rs | 27 +++++++++++---------------- lightning/src/onion_message/packet.rs | 21 ++++++++++----------- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index da3b93e0216..57fe33352ef 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -14,7 +14,7 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use crate::blinded_path::utils::{self, BlindedPathWithPadding}; use crate::blinded_path::{BlindedHop, BlindedPath, IntroductionNode, NodeIdLookUp}; -use crate::crypto::streams::ChaChaTriPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::io; use crate::io::Cursor; use crate::ln::channel_state::CounterpartyForwardingInfo; @@ -275,13 +275,13 @@ impl BlindedPaymentPath { &self.inner_path.blinded_hops.get(0).ok_or(())?.encrypted_payload; let mut s = Cursor::new(encrypted_control_tlvs); let mut reader = FixedLengthReader::new(&mut s, encrypted_control_tlvs.len() as u64); - let ChaChaTriPolyReadAdapter { readable, used_aad_a, used_aad_b } = + let ChaChaTriPolyReadAdapter { readable, used_aad } = ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; - match (&readable, used_aad_a || used_aad_b) { - (BlindedPaymentTlvs::Forward(_), false) - | (BlindedPaymentTlvs::Dummy(_), true) - | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), + match (&readable, used_aad == TriPolyAADUsed::NoAAD) { + (BlindedPaymentTlvs::Forward(_), true) + | (BlindedPaymentTlvs::Dummy(_), false) + | (BlindedPaymentTlvs::Receive(_), false) => Ok((readable, control_tlvs_ss)), _ => Err(()), } } diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index 6bda4078b49..37c7a681037 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -84,6 +84,18 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( plaintext } +#[derive(PartialEq, Eq)] +pub(crate) enum TriPolyAADUsed { + /// No AAD was used. + /// + /// The HMAC validated with standard ChaCha20Poly1305. + NoAAD, + /// The HMAC vlidated using the first AAD provided. + A, + /// The HMAC vlidated using the second AAD provided. + B, +} + /// Enables the use of the serialization macros for objects that need to be simultaneously decrypted /// and deserialized. This allows us to avoid an intermediate Vec allocation. /// @@ -98,8 +110,7 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( /// like classic ChaCha20Poly1305 for the non-AAD-containing MAC. pub(crate) struct ChaChaTriPolyReadAdapter { pub readable: R, - pub used_aad_a: bool, - pub used_aad_b: bool, + pub used_aad: TriPolyAADUsed, } impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> @@ -165,11 +176,11 @@ impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> let mut tag = [0 as u8; 16]; r.read_exact(&mut tag)?; if fixed_time_eq(&mac.result(), &tag) { - Ok(Self { readable, used_aad_a: false, used_aad_b: false }) + Ok(Self { readable, used_aad: TriPolyAADUsed::NoAAD }) } else if fixed_time_eq(&mac_aad_a.result(), &tag) { - Ok(Self { readable, used_aad_a: true, used_aad_b: false }) + Ok(Self { readable, used_aad: TriPolyAADUsed::A }) } else if fixed_time_eq(&mac_aad_b.result(), &tag) { - Ok(Self { readable, used_aad_a: false, used_aad_b: true }) + Ok(Self { readable, used_aad: TriPolyAADUsed::B }) } else { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 00d15d121f7..3b408cf1be1 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -56,7 +56,7 @@ use core::str::FromStr; #[cfg(feature = "std")] use std::net::SocketAddr; -use crate::crypto::streams::ChaChaTriPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::util::base32; use crate::util::logger; use crate::util::ser::{ @@ -3939,14 +3939,13 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo features, next_blinding_override, }), - used_aad_a, - used_aad_b, + used_aad, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad_a || used_aad_b + || used_aad != TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3962,14 +3961,13 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }), - used_aad_a, - used_aad_b, + used_aad, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || (!used_aad_a && !used_aad_b) + || used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3981,10 +3979,9 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo }, ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), - used_aad_a, - used_aad_b, + used_aad, } => { - if !used_aad_a && !used_aad_b { + if used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -4112,14 +4109,13 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline features, next_blinding_override, }), - used_aad_a, - used_aad_b, + used_aad, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad_a || used_aad_b + || used_aad != TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -4134,10 +4130,9 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline }, ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Receive(receive_tlvs), - used_aad_a, - used_aad_b, + used_aad, } => { - if !used_aad_a && !used_aad_b { + if used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index b7779e87f01..86cb47d30b0 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -19,7 +19,7 @@ use super::offers::OffersMessage; use crate::blinded_path::message::{ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs, }; -use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter}; +use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; @@ -337,29 +337,28 @@ impl None => return Err(DecodeError::InvalidValue), Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Forward(tlvs), - used_aad_a, - used_aad_b, + used_aad, }) => { - if used_aad_a || used_aad_b || message_type.is_some() { + if used_aad != TriPolyAADUsed::NoAAD || message_type.is_some() { return Err(DecodeError::InvalidValue); } Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs))) }, Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Dummy, - used_aad_a, - used_aad_b, - }) => Ok(Payload::Dummy { control_tlvs_authenticated: used_aad_a || used_aad_b }), + used_aad, + }) => Ok(Payload::Dummy { + control_tlvs_authenticated: used_aad != TriPolyAADUsed::NoAAD, + }), Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), - used_aad_a, - used_aad_b, + used_aad, }) => Ok(Payload::Receive { control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), reply_path, message: message.ok_or(DecodeError::InvalidValue)?, - control_tlvs_from_local_node: used_aad_a, - control_tlvs_from_phantom_participant: used_aad_b, + control_tlvs_from_local_node: used_aad == TriPolyAADUsed::A, + control_tlvs_from_phantom_participant: used_aad == TriPolyAADUsed::B, }), } } From ff0fbf9130be6021f4ac13693b75bcc360cd548a Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 23:08:46 +0000 Subject: [PATCH 06/14] f fix fuzz --- fuzz/src/onion_message.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 09634a1c373..70dfb0753d3 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -260,7 +260,7 @@ impl NodeSigner for KeyProvider { } fn get_expanded_key(&self) -> ExpandedKey { - unreachable!() + ExpandedKey::new([42; 32]) } fn sign_invoice( From 5bf55b19301164375ceff02d354f57edcf6a9958 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 12:41:44 +0000 Subject: [PATCH 07/14] f new names --- lightning/src/blinded_path/payment.rs | 2 +- lightning/src/crypto/streams.rs | 16 ++++++++-------- lightning/src/ln/msgs.rs | 10 +++++----- lightning/src/onion_message/packet.rs | 8 ++++---- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 57fe33352ef..03b676adc92 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -278,7 +278,7 @@ impl BlindedPaymentPath { let ChaChaTriPolyReadAdapter { readable, used_aad } = ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; - match (&readable, used_aad == TriPolyAADUsed::NoAAD) { + match (&readable, used_aad == TriPolyAADUsed::None) { (BlindedPaymentTlvs::Forward(_), true) | (BlindedPaymentTlvs::Dummy(_), false) | (BlindedPaymentTlvs::Receive(_), false) => Ok((readable, control_tlvs_ss)), diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index 37c7a681037..23a23154307 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -89,19 +89,19 @@ pub(crate) enum TriPolyAADUsed { /// No AAD was used. /// /// The HMAC validated with standard ChaCha20Poly1305. - NoAAD, + None, /// The HMAC vlidated using the first AAD provided. - A, + First, /// The HMAC vlidated using the second AAD provided. - B, + Second, } /// Enables the use of the serialization macros for objects that need to be simultaneously decrypted /// and deserialized. This allows us to avoid an intermediate Vec allocation. /// /// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags thrice, once using the given -/// key and twice with each of the two given 32-byte AADs appended after the encrypted stream, -/// accepting any being correct as sufficient. +/// key and once each for the two given 32-byte AADs appended after the encrypted stream, accepting +/// any being correct as sufficient. /// /// Note that we do *not* use the provided AADs as the standard ChaCha20Poly1305 AAD as that would /// require placing it first and prevent us from avoiding redundant Poly1305 rounds. Instead, the @@ -176,11 +176,11 @@ impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> let mut tag = [0 as u8; 16]; r.read_exact(&mut tag)?; if fixed_time_eq(&mac.result(), &tag) { - Ok(Self { readable, used_aad: TriPolyAADUsed::NoAAD }) + Ok(Self { readable, used_aad: TriPolyAADUsed::None }) } else if fixed_time_eq(&mac_aad_a.result(), &tag) { - Ok(Self { readable, used_aad: TriPolyAADUsed::A }) + Ok(Self { readable, used_aad: TriPolyAADUsed::First }) } else if fixed_time_eq(&mac_aad_b.result(), &tag) { - Ok(Self { readable, used_aad: TriPolyAADUsed::B }) + Ok(Self { readable, used_aad: TriPolyAADUsed::Second }) } else { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 3b408cf1be1..ac549ddd50c 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -3945,7 +3945,7 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad != TriPolyAADUsed::NoAAD + || used_aad != TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -3967,7 +3967,7 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad == TriPolyAADUsed::NoAAD + || used_aad == TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -3981,7 +3981,7 @@ impl ReadableArgs<(Option, NS)> for InboundOnionPaylo readable: BlindedPaymentTlvs::Receive(receive_tlvs), used_aad, } => { - if used_aad == TriPolyAADUsed::NoAAD { + if used_aad == TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -4115,7 +4115,7 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad != TriPolyAADUsed::NoAAD + || used_aad != TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } @@ -4132,7 +4132,7 @@ impl ReadableArgs<(Option, NS)> for InboundTrampoline readable: BlindedTrampolineTlvs::Receive(receive_tlvs), used_aad, } => { - if used_aad == TriPolyAADUsed::NoAAD { + if used_aad == TriPolyAADUsed::None { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index 86cb47d30b0..414232f0592 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -339,7 +339,7 @@ impl readable: ControlTlvs::Forward(tlvs), used_aad, }) => { - if used_aad != TriPolyAADUsed::NoAAD || message_type.is_some() { + if used_aad != TriPolyAADUsed::None || message_type.is_some() { return Err(DecodeError::InvalidValue); } Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs))) @@ -348,7 +348,7 @@ impl readable: ControlTlvs::Dummy, used_aad, }) => Ok(Payload::Dummy { - control_tlvs_authenticated: used_aad != TriPolyAADUsed::NoAAD, + control_tlvs_authenticated: used_aad != TriPolyAADUsed::None, }), Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), @@ -357,8 +357,8 @@ impl control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), reply_path, message: message.ok_or(DecodeError::InvalidValue)?, - control_tlvs_from_local_node: used_aad == TriPolyAADUsed::A, - control_tlvs_from_phantom_participant: used_aad == TriPolyAADUsed::B, + control_tlvs_from_local_node: used_aad == TriPolyAADUsed::First, + control_tlvs_from_phantom_participant: used_aad == TriPolyAADUsed::Second, }), } } From 100d36dd8d992f920fa114501f8ff08e0746b473 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 21 Jan 2026 21:36:17 +0000 Subject: [PATCH 08/14] Add methods to fetch an `OfferBuilder` for "phantom" node configs In the BOLT 11 world, we have specific support for what we call "phantom nodes" - creating invoices which can be paid to any one of a number of nodes by adding route-hints which represent nodes that do not exist. In BOLT 12, blinded paths make a similar feature much simpler - we can simply add blinded paths which terminate at different nodes. The blinding means that the sender is none the wiser. Here we add logic to fetch an `OfferBuilder` which can generate an offer payable to any one of a set of nodes. We retain the "phantom" terminology even though there are no longer any "phantom" nodes. Note that the current logic only supports the `invoice_request` message going to any of the participating nodes, it then replies with a `Bolt12Invoice` which can only be paid to the responding node. Future work may relax this restriction. --- lightning/src/ln/channelmanager.rs | 71 ++++++++++++ lightning/src/ln/functional_test_utils.rs | 32 +++++- lightning/src/ln/offers_tests.rs | 130 ++++++++++++++++++++-- lightning/src/offers/flow.rs | 55 ++++++++- lightning/src/util/test_utils.rs | 22 +++- 5 files changed, 291 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index bbede9589db..1c1c8764ab6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13402,6 +13402,45 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by any + /// [`ChannelManager`] (or [`OffersMessageFlow`]) using the same [`ExpandedKey`] (as returned + /// from [`NodeSigner::get_expanded_key`]). This allows any nodes participating in a BOLT 11 + /// "phantom node" cluster to also receive BOLT 12 payments. + /// + /// Note that, unlike with BOLT 11 invoices, BOLT 12 "phantom" offers do not in fact have any + /// "phantom node" appended to receiving paths. Instead, multiple blinded paths are simply + /// included which terminate at different final nodes. + /// + /// `other_nodes_channels` must be set to a list of each participating node's `node_id` (from + /// [`NodeSigner::get_node_id`] with a [`Recipient::Node`]) and its channels. + /// + /// `path_count_limit` is used to limit the number of blinded paths included in the resulting + /// [`Offer`]. Note that if this is less than the number of participating nodes (i.e. + /// `other_nodes_channels.len() + 1`) not all nodes will participate in receiving funds. + /// Because the parameterized [`MessageRouter`] will only get a chance to limit the number of + /// paths *per-node*, it is important to set this for offers which will be included in a QR + /// code. + /// + /// See [`Self::create_offer_builder`] for more details on the blinded path construction. + /// + /// [`ExpandedKey`]: inbound_payment::ExpandedKey + pub fn create_phantom_offer_builder( + &$self, other_nodes_channels: Vec<(PublicKey, Vec)>, + path_count_limit: usize, + ) -> Result<$builder, Bolt12SemanticError> { + let mut peers = Vec::with_capacity(other_nodes_channels.len() + 1); + peers.push(($self.get_our_node_id(), $self.get_peers_for_blinded_path())); + for (node_id, peer_chans) in other_nodes_channels { + peers.push((node_id, Self::channel_details_to_forward_node(peer_chans))); + } + + let builder = $self.flow.create_phantom_offer_builder( + &*$self.entropy_source, peers, path_count_limit + )?; + + Ok(builder.into()) + } } } macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { @@ -14018,6 +14057,38 @@ impl< now } + fn channel_details_to_forward_node( + mut channel_list: Vec, + ) -> Vec { + channel_list.sort_unstable_by_key(|chan| chan.counterparty.node_id); + let mut res = Vec::new(); + // TODO: When MSRV reaches 1.77 use chunk_by + let mut start = 0; + while start < channel_list.len() { + let counterparty_node_id = channel_list[start].counterparty.node_id; + let end = channel_list[start..] + .iter() + .position(|chan| chan.counterparty.node_id != counterparty_node_id) + .unwrap_or(channel_list.len()); + + let peer_chans = &channel_list[start..end]; + if peer_chans.iter().any(|chan| chan.is_usable) + && peer_chans.iter().any(|c| c.counterparty.features.supports_onion_messages()) + { + res.push(MessageForwardNode { + node_id: peer_chans[0].counterparty.node_id, + short_channel_id: peer_chans + .iter() + .filter(|chan| chan.is_usable) + .filter_map(|chan| chan.short_channel_id) + .min(), + }) + } + start = end; + } + res + } + fn get_peers_for_blinded_path(&self) -> Vec { let per_peer_state = self.per_peer_state.read().unwrap(); per_peer_state diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index e8965752331..01de988144b 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4405,21 +4405,41 @@ pub fn create_chanmon_cfgs(node_count: usize) -> Vec { pub fn create_chanmon_cfgs_with_legacy_keys( node_count: usize, predefined_keys_ids: Option>, +) -> Vec { + create_chanmon_cfgs_internal(node_count, predefined_keys_ids, false) +} + +pub fn create_phantom_chanmon_cfgs(node_count: usize) -> Vec { + create_chanmon_cfgs_internal(node_count, None, true) +} + +pub fn create_chanmon_cfgs_internal( + node_count: usize, predefined_keys_ids: Option>, phantom: bool, ) -> Vec { let mut chan_mon_cfgs = Vec::new(); + let phantom_seed = if phantom { Some(&[42; 32]) } else { None }; for i in 0..node_count { let tx_broadcaster = test_utils::TestBroadcaster::new(Network::Testnet); let fee_estimator = test_utils::TestFeeEstimator::new(253); let chain_source = test_utils::TestChainSource::new(Network::Testnet); let logger = test_utils::TestLogger::with_id(format!("node {}", i)); let persister = test_utils::TestPersister::new(); - let seed = [i as u8; 32]; - let keys_manager = if predefined_keys_ids.is_some() { + let mut seed = [i as u8; 32]; + if phantom { + // We would ideally randomize keys on every test run, but some tests fail in that case. + // Instead, we only randomize in the phantom case. + use core::hash::{BuildHasher, Hasher}; + // Get a random value using the only std API to do so - the DefaultHasher + let rand_val = std::collections::hash_map::RandomState::new().build_hasher().finish(); + seed[..8].copy_from_slice(&rand_val.to_ne_bytes()); + } + let keys_manager = test_utils::TestKeysInterface::with_settings( + &seed, + Network::Testnet, // Use legacy (V1) remote_key derivation for tests using legacy key sets. - test_utils::TestKeysInterface::with_v1_remote_key_derivation(&seed, Network::Testnet) - } else { - test_utils::TestKeysInterface::new(&seed, Network::Testnet) - }; + predefined_keys_ids.is_some(), + phantom_seed, + ); let scorer = RwLock::new(test_utils::TestScorer::new()); // Set predefined keys_id if provided diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 12e631b4042..09a2c885432 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -75,15 +75,21 @@ const MAX_SHORT_LIVED_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * use crate::prelude::*; macro_rules! expect_recent_payment { - ($node: expr, $payment_state: path, $payment_id: expr) => { - match $node.node.list_recent_payments().first() { - Some(&$payment_state { payment_id: actual_payment_id, .. }) => { - assert_eq!($payment_id, actual_payment_id); - }, - Some(_) => panic!("Unexpected recent payment state"), - None => panic!("No recent payments"), + ($node: expr, $payment_state: path, $payment_id: expr) => {{ + let mut found_payment = false; + for payment in $node.node.list_recent_payments().iter() { + match payment { + $payment_state { payment_id: actual_payment_id, .. } => { + if $payment_id == *actual_payment_id { + found_payment = true; + break; + } + }, + _ => {}, + } } - } + assert!(found_payment); + }} } fn connect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>) { @@ -2572,3 +2578,111 @@ fn no_double_pay_with_stale_channelmanager() { // generated in response to the duplicate invoice. assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } + +#[test] +fn creates_and_pays_for_phantom_offer() { + // XXX: share expanded key + let mut chanmon_cfgs = create_chanmon_cfgs(1); + chanmon_cfgs.append(&mut create_phantom_chanmon_cfgs(2)); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 0, 2, 10_000_000, 1_000_000_000); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + + let offer = nodes[1].node + .create_phantom_offer_builder(vec![(node_c_id, nodes[2].node.list_channels())], 2) + .unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // The offer should be resolvable by either of node B or C but signed by a derived key + assert!(offer.issuer_signing_pubkey().is_some()); + assert_ne!(offer.issuer_signing_pubkey(), Some(node_b_id)); + assert_ne!(offer.issuer_signing_pubkey(), Some(node_c_id)); + assert_eq!(offer.paths().len(), 2); + let mut b_path_count = 0; + let mut c_path_count = 0; + for path in offer.paths() { + if check_compact_path_introduction_node(&path, &nodes[0], node_b_id) { + b_path_count += 1; + } + if check_compact_path_introduction_node(&path, &nodes[0], node_c_id) { + c_path_count += 1; + } + } + assert_eq!(b_path_count, 1); + assert_eq!(c_path_count, 1); + + // First, pay via node B + let payment_id = PaymentId([1; 32]); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); + let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); + nodes[1].onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&nodes[1], &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = nodes[1].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(node_b_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[&nodes[1]], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[&nodes[1]], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + + // Then pay again via node C + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); + + let payment_id = PaymentId([2; 32]); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); + let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); + nodes[2].onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&nodes[2], &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = nodes[2].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(node_c_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[&nodes[2]], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[&nodes[2]], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 0bb98777227..9fa27cec3a2 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -286,6 +286,39 @@ impl OffersMessageFlow { self.create_blinded_paths(peers, context) } + fn blinded_paths_for_phantom_offer( + &self, per_node_peers: Vec<(PublicKey, Vec)>, path_count_limit: usize, + context: MessageContext, + ) -> Result, ()> { + let receive_key = ReceiveAuthKey(self.inbound_payment_key.phantom_node_blinded_path_key); + let secp_ctx = &self.secp_ctx; + + let mut per_node_paths: Vec<_> = per_node_peers + .into_iter() + .filter_map(|(recipient, peers)| { + self.message_router + .create_blinded_paths(recipient, receive_key, context.clone(), peers, secp_ctx) + .ok() + }) + .collect(); + + let mut res = Vec::new(); + while res.len() < path_count_limit && !per_node_paths.is_empty() { + for node_paths in per_node_paths.iter_mut() { + if let Some(path) = node_paths.pop() { + res.push(path); + } + } + per_node_paths.retain(|node_paths| !node_paths.is_empty()); + } + + if res.is_empty() { + Err(()) + } else { + Ok(res) + } + } + /// Creates a collection of blinded paths by delegating to /// [`MessageRouter::create_blinded_paths`]. /// @@ -559,8 +592,7 @@ impl OffersMessageFlow { /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the /// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using - /// [`Self::verify_invoice_request`]. The offer will expire at `absolute_expiry` if `Some`, - /// or will not expire if `None`. + /// [`Self::verify_invoice_request`]. /// /// # Privacy /// @@ -634,6 +666,25 @@ impl OffersMessageFlow { }) } + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by any + /// [`OffersMessageFlow`] using the same [`ExpandedKey`] (provided in the constructor as + /// `inbound_payment_key`), and any corresponding [`InvoiceRequest`] can be verified using + /// [`Self::verify_invoice_request`]. + /// + /// See [`Self::create_offer_builder`] for more details on privacy and limitations. + /// + /// [`ExpandedKey`]: inbound_payment::ExpandedKey + pub fn create_phantom_offer_builder( + &self, entropy_source: ES, per_node_peers: Vec<(PublicKey, Vec)>, + path_count_limit: usize, + ) -> Result, Bolt12SemanticError> { + self.create_offer_builder_intern(&*entropy_source, |_, context, _| { + self.blinded_paths_for_phantom_offer(per_node_peers, path_count_limit, context) + .map_err(|_| Bolt12SemanticError::MissingPaths) + }) + .map(|(builder, _)| builder) + } + fn create_refund_builder_intern( &self, entropy_source: ES, make_paths: PF, amount_msats: u64, absolute_expiry: Duration, payment_id: PaymentId, diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index a12b113b293..f9115e4bbcf 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1954,6 +1954,7 @@ pub trait TestSignerFactory: Send + Sync { /// Make a dynamic signer fn make_signer( &self, seed: &[u8; 32], now: Duration, v2_remote_key_derivation: bool, + phantom_seed: Option<&[u8; 32]>, ) -> Box>; } @@ -1963,12 +1964,13 @@ struct DefaultSignerFactory(); impl TestSignerFactory for DefaultSignerFactory { fn make_signer( &self, seed: &[u8; 32], now: Duration, v2_remote_key_derivation: bool, + phantom_seed: Option<&[u8; 32]>, ) -> Box> { let phantom = sign::PhantomKeysManager::new( seed, now.as_secs(), now.subsec_nanos(), - seed, + if let Some(provided_seed) = phantom_seed { provided_seed } else { seed }, v2_remote_key_derivation, ); let dphantom = DynPhantomKeysInterface::new(phantom); @@ -2000,7 +2002,7 @@ impl TestKeysInterface { let factory = DefaultSignerFactory(); let now = Duration::from_secs(genesis_block(network).header.time as u64); - let backing = factory.make_signer(seed, now, true); + let backing = factory.make_signer(seed, now, true, None); Self::build(backing) } @@ -2012,7 +2014,21 @@ impl TestKeysInterface { let factory = DefaultSignerFactory(); let now = Duration::from_secs(genesis_block(network).header.time as u64); - let backing = factory.make_signer(seed, now, false); + let backing = factory.make_signer(seed, now, false, None); + Self::build(backing) + } + + pub fn with_settings( + seed: &[u8; 32], network: Network, v1_derivation: bool, phantom_seed: Option<&[u8; 32]>, + ) -> Self { + #[cfg(feature = "std")] + let factory = SIGNER_FACTORY.get(); + + #[cfg(not(feature = "std"))] + let factory = DefaultSignerFactory(); + + let now = Duration::from_secs(genesis_block(network).header.time as u64); + let backing = factory.make_signer(seed, now, !v1_derivation, phantom_seed); Self::build(backing) } From 1da6959205f0062df9751bc1f93526d9b5653fcc Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 21:06:47 +0000 Subject: [PATCH 09/14] f fix offset --- lightning/src/ln/channelmanager.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1c1c8764ab6..b0263c3a87a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -14069,6 +14069,7 @@ impl< let end = channel_list[start..] .iter() .position(|chan| chan.counterparty.node_id != counterparty_node_id) + .map(|pos| start + pos) .unwrap_or(channel_list.len()); let peer_chans = &channel_list[start..end]; From 3b7c1e3cb533c45b5ef7be023c66c79d9bfdd975 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 21:09:14 +0000 Subject: [PATCH 10/14] f cleanup test - make it a loop and remove old XXX --- lightning/src/ln/offers_tests.rs | 115 +++++++++++++------------------ 1 file changed, 48 insertions(+), 67 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 09a2c885432..a4a09dd1910 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2581,7 +2581,7 @@ fn no_double_pay_with_stale_channelmanager() { #[test] fn creates_and_pays_for_phantom_offer() { - // XXX: share expanded key + // Tests that we can pay a "phantom offer" to any participating node. let mut chanmon_cfgs = create_chanmon_cfgs(1); chanmon_cfgs.append(&mut create_phantom_chanmon_cfgs(2)); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); @@ -2619,70 +2619,51 @@ fn creates_and_pays_for_phantom_offer() { assert_eq!(b_path_count, 1); assert_eq!(c_path_count, 1); - // First, pay via node B - let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); - expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); - - let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); - let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); - nodes[1].onion_messenger.handle_onion_message(node_a_id, &onion_message); - - let (invoice_request, _) = extract_invoice_request(&nodes[1], &onion_message); - let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: offer.id(), - invoice_request: InvoiceRequestFields { - payer_signing_pubkey: invoice_request.payer_signing_pubkey(), - quantity: None, - payer_note_truncated: None, - human_readable_name: None, - }, - }); - - let onion_message = nodes[1].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); - nodes[0].onion_messenger.handle_onion_message(node_b_id, &onion_message); - - let (invoice, _) = extract_invoice(&nodes[0], &onion_message); - assert_eq!(invoice.amount_msats(), 10_000_000); - - route_bolt12_payment(&nodes[0], &[&nodes[1]], &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); - - claim_bolt12_payment(&nodes[0], &[&nodes[1]], payment_context, &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); - - // Then pay again via node C - assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); - assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); - - let payment_id = PaymentId([2; 32]); - nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); - expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); - - let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); - let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); - nodes[2].onion_messenger.handle_onion_message(node_a_id, &onion_message); - - let (invoice_request, _) = extract_invoice_request(&nodes[2], &onion_message); - let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: offer.id(), - invoice_request: InvoiceRequestFields { - payer_signing_pubkey: invoice_request.payer_signing_pubkey(), - quantity: None, - payer_note_truncated: None, - human_readable_name: None, - }, - }); - - let onion_message = nodes[2].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); - nodes[0].onion_messenger.handle_onion_message(node_c_id, &onion_message); - - let (invoice, _) = extract_invoice(&nodes[0], &onion_message); - assert_eq!(invoice.amount_msats(), 10_000_000); - - route_bolt12_payment(&nodes[0], &[&nodes[2]], &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); - - claim_bolt12_payment(&nodes[0], &[&nodes[2]], payment_context, &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + // Pay twice, first via node B (the node that actually built the offer) then pay via node C + // (which won't have seen the offer until it receives the invoice_request). + for (payment_id, recipient) in [([1; 32], &nodes[1]), ([2; 32], &nodes[2])] { + let payment_id = PaymentId(payment_id); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let recipient_id = recipient.node.get_our_node_id(); + let non_recipient_id = if node_b_id == recipient_id { + node_c_id + } else { + node_b_id + }; + + let onion_message = + nodes[0].onion_messenger.next_onion_message_for_peer(recipient_id).unwrap(); + let _discard = + nodes[0].onion_messenger.next_onion_message_for_peer(non_recipient_id).unwrap(); + recipient.onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&recipient, &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = + recipient.onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(recipient_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[recipient], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[recipient], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); + } } From ca740565f828909eb41c25354a2f58cb21de603d Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 27 Jan 2026 20:17:14 +0000 Subject: [PATCH 11/14] f add docs on channel_details_to_forward_nodes --- lightning/src/ln/channelmanager.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b0263c3a87a..2240408dcfa 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13432,7 +13432,7 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { let mut peers = Vec::with_capacity(other_nodes_channels.len() + 1); peers.push(($self.get_our_node_id(), $self.get_peers_for_blinded_path())); for (node_id, peer_chans) in other_nodes_channels { - peers.push((node_id, Self::channel_details_to_forward_node(peer_chans))); + peers.push((node_id, Self::channel_details_to_forward_nodes(peer_chans))); } let builder = $self.flow.create_phantom_offer_builder( @@ -14057,7 +14057,9 @@ impl< now } - fn channel_details_to_forward_node( + /// Converts a list of channels to a list of peers which may be suitable to receive onion + /// messages through. + fn channel_details_to_forward_nodes( mut channel_list: Vec, ) -> Vec { channel_list.sort_unstable_by_key(|chan| chan.counterparty.node_id); From dc138af0e85cf0f3ce0ad7eed12bb529b5505482 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 12:41:59 +0000 Subject: [PATCH 12/14] f sp --- lightning/src/ln/channelmanager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2240408dcfa..ff1e4ca09dc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13419,7 +13419,7 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { /// [`Offer`]. Note that if this is less than the number of participating nodes (i.e. /// `other_nodes_channels.len() + 1`) not all nodes will participate in receiving funds. /// Because the parameterized [`MessageRouter`] will only get a chance to limit the number of - /// paths *per-node*, it is important to set this for offers which will be included in a QR + /// paths *per-node*, it is important to set this for offers that will be included in a QR /// code. /// /// See [`Self::create_offer_builder`] for more details on the blinded path construction. From cece94a4ea60d5746b26aaea44b7a72d0a7148a5 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 12:42:14 +0000 Subject: [PATCH 13/14] f rebase --- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/offers/flow.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ff1e4ca09dc..41baf68bd87 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13436,7 +13436,7 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { } let builder = $self.flow.create_phantom_offer_builder( - &*$self.entropy_source, peers, path_count_limit + &$self.entropy_source, peers, path_count_limit )?; Ok(builder.into()) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 9fa27cec3a2..efd53035158 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -678,7 +678,7 @@ impl OffersMessageFlow { &self, entropy_source: ES, per_node_peers: Vec<(PublicKey, Vec)>, path_count_limit: usize, ) -> Result, Bolt12SemanticError> { - self.create_offer_builder_intern(&*entropy_source, |_, context, _| { + self.create_offer_builder_intern(entropy_source, |_, context, _| { self.blinded_paths_for_phantom_offer(per_node_peers, path_count_limit, context) .map_err(|_| Bolt12SemanticError::MissingPaths) }) From 5a672bdad4531997541c1b479c78b7087fbbe6c2 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 5 Feb 2026 12:42:20 +0000 Subject: [PATCH 14/14] f correct scid used --- lightning/src/ln/channelmanager.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 41baf68bd87..0da4c52dc04 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -14083,8 +14083,8 @@ impl< short_channel_id: peer_chans .iter() .filter(|chan| chan.is_usable) - .filter_map(|chan| chan.short_channel_id) - .min(), + .min_by_key(|chan| chan.short_channel_id) + .and_then(|chan| chan.get_inbound_payment_scid()), }) } start = end;