From ebf378c91cf015a351b1bfee8e6a0e18008dbad6 Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 25 Aug 2025 17:24:01 -0400 Subject: [PATCH 01/15] Compute trampoline session_priv from outer session_priv This simplifies the code and makes it more straightforward to test unblinded trampoline receives where we need to compute the trampoline session_priv when manually creating the inner onion. (The trampoline onion needs to be manually created because LDK does not natively support sending to unblinded trampolines, just receiving.) --- lightning/src/ln/blinded_payment_tests.rs | 27 ++---- lightning/src/ln/onion_utils.rs | 104 +++++++++------------- 2 files changed, 51 insertions(+), 80 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 0631db3d408..ae2b6b2ca94 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -10,8 +10,6 @@ // licenses. use bitcoin::hashes::hex::FromHex; -use bitcoin::hashes::sha256::Hash as Sha256; -use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey, schnorr}; use bitcoin::secp256k1::ecdh::SharedSecret; @@ -1830,7 +1828,7 @@ fn test_combined_trampoline_onion_creation_vectors() { let amt_msat = 150_000_000; let cur_height = 800_000; let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); - let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &session_priv, amt_msat, &recipient_onion_fields, cur_height, &associated_data, &None, None, [0; 32], Some(outer_session_key), Some(outer_onion_prng_seed)).unwrap(); + let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &outer_session_key, amt_msat, &recipient_onion_fields, cur_height, &associated_data, &None, None, outer_onion_prng_seed, Some(session_priv), Some([0; 32])).unwrap(); let outer_onion_packet_hex = bob_onion.encode().to_lower_hex_string(); assert_eq!(outer_onion_packet_hex, "00025fd60556c134ae97e4baedba220a644037754ee67c54fd05e93bf40c17cbb73362fb9dee96001ff229945595b6edb59437a6bc143406d3f90f749892a84d8d430c6890437d26d5bfc599d565316ef51347521075bbab87c59c57bcf20af7e63d7192b46cf171e4f73cb11f9f603915389105d91ad630224bea95d735e3988add1e24b5bf28f1d7128db64284d90a839ba340d088c74b1fb1bd21136b1809428ec5399c8649e9bdf92d2dcfc694deae5046fa5b2bdf646847aaad73f5e95275763091c90e71031cae1f9a770fdea559642c9c02f424a2a28163dd0957e3874bd28a97bec67d18c0321b0e68bc804aa8345b17cb626e2348ca06c8312a167c989521056b0f25c55559d446507d6c491d50605cb79fa87929ce64b0a9860926eeaec2c431d926a1cadb9a1186e4061cb01671a122fc1f57602cbef06d6c194ec4b715c2e3dd4120baca3172cd81900b49fef857fb6d6afd24c983b608108b0a5ac0c1c6c52011f23b8778059ffadd1bb7cd06e2525417365f485a7fd1d4a9ba3818ede7cdc9e71afee8532252d08e2531ca52538655b7e8d912f7ec6d37bbcce8d7ec690709dbf9321e92c565b78e7fe2c22edf23e0902153d1ca15a112ad32fb19695ec65ce11ddf670da7915f05ad4b86c154fb908cb567315d1124f303f75fa075ebde8ef7bb12e27737ad9e4924439097338ea6d7a6fc3721b88c9b830a34e8d55f4c582b74a3895cc848fe57f4fe29f115dabeb6b3175be15d94408ed6771109cfaf57067ae658201082eae7605d26b1449af4425ae8e8f58cdda5c6265f1fd7a386fc6cea3074e4f25b909b96175883676f7610a00fdf34df9eb6c7b9a4ae89b839c69fd1f285e38cdceb634d782cc6d81179759bc9fd47d7fd060470d0b048287764c6837963274e708314f017ac7dc26d0554d59bfcfd3136225798f65f0b0fea337c6b256ebbb63a90b994c0ab93fd8b1d6bd4c74aebe535d6110014cd3d525394027dfe8faa98b4e9b2bee7949eb1961f1b026791092f84deea63afab66603dbe9b6365a102a1fef2f6b9744bc1bb091a8da9130d34d4d39f25dbad191649cfb67e10246364b7ce0c6ec072f9690cabb459d9fda0c849e17535de4357e9907270c75953fca3c845bb613926ecf73205219c7057a4b6bb244c184362bb4e2f24279dc4e60b94a5b1ec11c34081a628428ba5646c995b9558821053ba9c84a05afbf00dabd60223723096516d2f5668f3ec7e11612b01eb7a3a0506189a2272b88e89807943adb34291a17f6cb5516ffd6f945a1c42a524b21f096d66f350b1dad4db455741ae3d0e023309fbda5ef55fb0dc74f3297041448b2be76c525141963934c6afc53d263fb7836626df502d7c2ee9e79cbbd87afd84bbb8dfbf45248af3cd61ad5fac827e7683ca4f91dfad507a8eb9c17b2c9ac5ec051fe645a4a6cb37136f6f19b611e0ea8da7960af2d779507e55f57305bc74b7568928c5dd5132990fe54c22117df91c257d8c7b61935a018a28c1c3b17bab8e4294fa699161ec21123c9fc4e71079df31f300c2822e1246561e04765d3aab333eafd026c7431ac7616debb0e022746f4538e1c6348b600c988eeb2d051fc60c468dca260a84c79ab3ab8342dc345a764672848ea234e17332bc124799daf7c5fcb2e2358514a7461357e1c19c802c5ee32deccf1776885dd825bedd5f781d459984370a6b7ae885d4483a76ddb19b30f47ed47cd56aa5a079a89793dbcad461c59f2e002067ac98dd5a534e525c9c46c2af730741bf1f8629357ec0bfc0bc9ecb31af96777e507648ff4260dc3673716e098d9111dfd245f1d7c55a6de340deb8bd7a053e5d62d760f184dc70ca8fa255b9023b9b9aedfb6e419a5b5951ba0f83b603793830ee68d442d7b88ee1bbf6bbd1bcd6f68cc1af"); @@ -2010,7 +2008,12 @@ fn do_test_trampoline_single_hop_receive(success: bool) { let amt_msat = 1000; let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); - let carol_alice_trampoline_session_priv = secret_from_hex("a0f4b8d7b6c2d0ffdfaf718f76e9decaef4d9fb38a8c4addb95c4007cc3eee03"); + // We need the session priv to compute the trampoline session priv and construct an invalid onion packet later. + let override_random_bytes = [3; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); + + let outer_onion_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); + let carol_alice_trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_onion_session_priv); let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &carol_alice_trampoline_session_priv); let carol_blinded_hops = if success { let payee_tlvs = UnauthenticatedReceiveTlvs { @@ -2098,12 +2101,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { route_params: None, }; - // We need the session priv to construct an invalid onion packet later. - let override_random_bytes = [3; 32]; - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); - check_added_monitors!(&nodes[0], 1); if success { @@ -2112,9 +2110,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { } else { let replacement_onion = { // create a substitute onion where the last Trampoline hop is a forward - let trampoline_secret_key = SecretKey::from_slice(&override_random_bytes).unwrap(); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); - let mut blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); // append some dummy blinded hop so the intro hop looks like a forward @@ -2128,7 +2124,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { // pop the last dummy hop trampoline_payloads.pop(); - let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_secret_key); + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &carol_alice_trampoline_session_priv); let trampoline_packet = onion_utils::construct_trampoline_onion_packet( trampoline_payloads, trampoline_onion_keys, @@ -2137,13 +2133,8 @@ fn do_test_trampoline_single_hop_receive(success: bool) { None, ).unwrap(); - let outer_session_priv = { - let session_priv_hash = Sha256::hash(&override_random_bytes).to_byte_array(); - SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") - }; - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); - let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); + let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_onion_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, outer_onion_keys, diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index d45860b0e26..0bcfbce57cb 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -992,41 +992,19 @@ pub fn process_onion_failure( where L::Target: Logger, { - let (path, primary_session_priv) = match htlc_source { + let (path, session_priv) = match htlc_source { HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => (path, session_priv), _ => unreachable!(), }; - if path.has_trampoline_hops() { - // If we have Trampoline hops, the outer onion session_priv is a hash of the inner one. - let session_priv_hash = Sha256::hash(&primary_session_priv.secret_bytes()).to_byte_array(); - let outer_session_priv = - SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!"); - process_onion_failure_inner( - secp_ctx, - logger, - path, - &outer_session_priv, - Some(primary_session_priv), - encrypted_packet, - ) - } else { - process_onion_failure_inner( - secp_ctx, - logger, - path, - primary_session_priv, - None, - encrypted_packet, - ) - } + process_onion_failure_inner(secp_ctx, logger, path, &session_priv, None, encrypted_packet) } /// Process failure we got back from upstream on a payment we sent (implying htlc_source is an /// OutboundRoute). fn process_onion_failure_inner( - secp_ctx: &Secp256k1, logger: &L, path: &Path, outer_session_priv: &SecretKey, - inner_session_priv: Option<&SecretKey>, mut encrypted_packet: OnionErrorPacket, + secp_ctx: &Secp256k1, logger: &L, path: &Path, session_priv: &SecretKey, + trampoline_session_priv_override: Option, mut encrypted_packet: OnionErrorPacket, ) -> DecodedOnionFailure where L::Target: Logger, @@ -1097,22 +1075,27 @@ where let nontrampoline_bt = if path.has_trampoline_hops() { None } else { path.blinded_tail.as_ref() }; let nontrampolines = - construct_onion_keys_generic(secp_ctx, &path.hops, nontrampoline_bt, outer_session_priv) - .map(|(shared_secret, _, _, route_hop_option, _)| { + construct_onion_keys_generic(secp_ctx, &path.hops, nontrampoline_bt, session_priv).map( + |(shared_secret, _, _, route_hop_option, _)| { (route_hop_option.map(|rh| ErrorHop::RouteHop(rh)), shared_secret) - }); + }, + ); let trampolines = if path.has_trampoline_hops() { // Trampoline hops are part of the blinded tail, so this can never panic let blinded_tail = path.blinded_tail.as_ref(); let hops = &blinded_tail.unwrap().trampoline_hops; - let inner_session_priv = - inner_session_priv.expect("Trampoline hops always have an inner session priv"); - Some(construct_onion_keys_generic(secp_ctx, hops, blinded_tail, inner_session_priv).map( - |(shared_secret, _, _, route_hop_option, _)| { - (route_hop_option.map(|tram_hop| ErrorHop::TrampolineHop(tram_hop)), shared_secret) - }, - )) + let trampoline_session_priv = trampoline_session_priv_override + .unwrap_or_else(|| compute_trampoline_session_priv(session_priv)); + Some( + construct_onion_keys_generic(secp_ctx, hops, blinded_tail, &trampoline_session_priv) + .map(|(shared_secret, _, _, route_hop_option, _)| { + ( + route_hop_option.map(|tram_hop| ErrorHop::TrampolineHop(tram_hop)), + shared_secret, + ) + }), + ) } else { None }; @@ -2513,18 +2496,24 @@ pub fn create_payment_onion( ) } +pub(super) fn compute_trampoline_session_priv(outer_onion_session_priv: &SecretKey) -> SecretKey { + // When creating the inner trampoline onion, we set the session priv to the hash of the outer + // onion session priv. + let session_priv_hash = Sha256::hash(&outer_onion_session_priv.secret_bytes()).to_byte_array(); + SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") +} + /// Build a payment onion, returning the first hop msat and cltv values as well. /// `cur_block_height` should be set to the best known block height + 1. pub(crate) fn create_payment_onion_internal( secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, total_msat: u64, recipient_onion: &RecipientOnionFields, cur_block_height: u32, payment_hash: &PaymentHash, keysend_preimage: &Option, invoice_request: Option<&InvoiceRequest>, - prng_seed: [u8; 32], secondary_session_priv: Option, - secondary_prng_seed: Option<[u8; 32]>, + prng_seed: [u8; 32], trampoline_session_priv_override: Option, + trampoline_prng_seed_override: Option<[u8; 32]>, ) -> Result<(msgs::OnionPacket, u64, u32), APIError> { let mut outer_total_msat = total_msat; let mut outer_starting_htlc_offset = cur_block_height; - let mut outer_session_priv_override = None; let mut trampoline_packet_option = None; if let Some(blinded_tail) = &path.blinded_tail { @@ -2539,12 +2528,15 @@ pub(crate) fn create_payment_onion_internal( keysend_preimage, )?; + let trampoline_session_priv = trampoline_session_priv_override + .unwrap_or_else(|| compute_trampoline_session_priv(session_priv)); + let trampoline_prng_seed = trampoline_prng_seed_override.unwrap_or(prng_seed); let onion_keys = - construct_trampoline_onion_keys(&secp_ctx, &blinded_tail, &session_priv); + construct_trampoline_onion_keys(&secp_ctx, &blinded_tail, &trampoline_session_priv); let trampoline_packet = construct_trampoline_onion_packet( trampoline_payloads, onion_keys, - prng_seed, + trampoline_prng_seed, payment_hash, // TODO: specify a fixed size for privacy in future spec upgrade None, @@ -2554,11 +2546,6 @@ pub(crate) fn create_payment_onion_internal( })?; trampoline_packet_option = Some(trampoline_packet); - - outer_session_priv_override = Some(secondary_session_priv.unwrap_or_else(|| { - let session_priv_hash = Sha256::hash(&session_priv.secret_bytes()).to_byte_array(); - SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") - })); } } @@ -2572,14 +2559,11 @@ pub(crate) fn create_payment_onion_internal( trampoline_packet_option, )?; - let outer_session_priv = outer_session_priv_override.as_ref().unwrap_or(session_priv); - let onion_keys = construct_onion_keys(&secp_ctx, &path, outer_session_priv); - let outer_onion_prng_seed = secondary_prng_seed.unwrap_or(prng_seed); - let onion_packet = - construct_onion_packet(onion_payloads, onion_keys, outer_onion_prng_seed, payment_hash) - .map_err(|_| APIError::InvalidRoute { - err: "Route size too large considering onion data".to_owned(), - })?; + let onion_keys = construct_onion_keys(&secp_ctx, &path, session_priv); + let onion_packet = construct_onion_packet(onion_payloads, onion_keys, prng_seed, payment_hash) + .map_err(|_| APIError::InvalidRoute { + err: "Route size too large considering onion data".to_owned(), + })?; Ok((onion_packet, htlc_msat, htlc_cltv)) } @@ -3571,7 +3555,7 @@ mod tests { &logger, &build_trampoline_test_path(), &outer_session_priv, - Some(&trampoline_session_priv), + Some(trampoline_session_priv), error_packet, ); assert_eq!( @@ -3584,19 +3568,15 @@ mod tests { // shared secret cryptography sanity tests let session_priv = get_test_session_key(); let path = build_trampoline_test_path(); + let outer_onion_keys = construct_onion_keys(&Secp256k1::new(), &path, &session_priv); + let trampoline_session_priv = compute_trampoline_session_priv(&session_priv); let trampoline_onion_keys = construct_trampoline_onion_keys( &secp_ctx, &path.blinded_tail.as_ref().unwrap(), - &session_priv, + &trampoline_session_priv, ); - let outer_onion_keys = { - let session_priv_hash = Sha256::hash(&session_priv.secret_bytes()).to_byte_array(); - let outer_session_priv = SecretKey::from_slice(&session_priv_hash[..]).unwrap(); - construct_onion_keys(&Secp256k1::new(), &path, &outer_session_priv) - }; - let htlc_source = HTLCSource::OutboundRoute { path, session_priv, From 69f8a65535c3fdf80faa5c2c1b13cf12a4f65fbe Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 25 Aug 2025 18:28:26 -0400 Subject: [PATCH 02/15] Simplify test_trampoline_unblinded_receive No need to construct unused blinded hop data or hardcode session privs/prng seeds. --- lightning/src/ln/blinded_payment_tests.rs | 57 +++++++---------------- 1 file changed, 18 insertions(+), 39 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index ae2b6b2ca94..5b987901a7a 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2216,7 +2216,6 @@ fn test_trampoline_unblinded_receive() { connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); } - let alice_node_id = nodes[0].node().get_our_node_id(); let bob_node_id = nodes[1].node().get_our_node_id(); let carol_node_id = nodes[2].node().get_our_node_id(); @@ -2225,28 +2224,6 @@ fn test_trampoline_unblinded_receive() { let amt_msat = 1000; let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); - let payee_tlvs = blinded_path::payment::TrampolineForwardTlvs { - next_trampoline: alice_node_id, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: amt_msat, - }, - features: BlindedHopFeatures::empty(), - payment_relay: PaymentRelay { - cltv_expiry_delta: 0, - fee_proportional_millionths: 0, - fee_base_msat: 0, - }, - next_blinding_override: None, - }; - - let carol_unblinded_tlvs = payee_tlvs.encode(); - let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; - let carol_alice_trampoline_session_priv = secret_from_hex("a0f4b8d7b6c2d0ffdfaf718f76e9decaef4d9fb38a8c4addb95c4007cc3eee03"); - let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &carol_alice_trampoline_session_priv); - let carol_blinded_hops = blinded_path::utils::construct_blinded_hops( - &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, - ).unwrap(); let route = Route { paths: vec![Path { @@ -2283,8 +2260,12 @@ fn test_trampoline_unblinded_receive() { cltv_expiry_delta: 24, }, ], - hops: carol_blinded_hops, - blinding_point: carol_blinding_point, + // The blinded path data is unused because we replace the onion of the last hop + hops: vec![BlindedHop { + blinded_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + encrypted_payload: vec![42; 32] + }], + blinding_point: PublicKey::from_slice(&[2; 33]).unwrap(), excess_final_cltv_expiry_delta: 39, final_value_msat: amt_msat, }) @@ -2292,49 +2273,47 @@ fn test_trampoline_unblinded_receive() { route_params: None, }; + // We need the session priv to construct an invalid onion packet later. + let override_random_bytes = [42; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); let replacement_onion = { // create a substitute onion where the last Trampoline hop is an unblinded receive, which we // (deliberately) do not support out of the box, therefore necessitating this workaround - let trampoline_secret_key = secret_from_hex("0134928f7b7ca6769080d70f16be84c812c741f545b49a34db47ce338a205799"); - let prng_seed = secret_from_hex("fe02b4b9054302a3ddf4e1e9f7c411d644aebbd295218ab009dca94435f775a9"); + let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); + let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); - - // pop the last dummy hop - trampoline_payloads.pop(); - - trampoline_payloads.push(msgs::OutboundTrampolinePayload::Receive { + let (_, _, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + let trampoline_payloads = vec![msgs::OutboundTrampolinePayload::Receive { payment_data: Some(msgs::FinalOnionHopData { payment_secret, total_msat: amt_msat, }), sender_intended_htlc_amt_msat: amt_msat, cltv_expiry_height: 104, - }); + }]; - let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_secret_key); + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_session_priv); let trampoline_packet = onion_utils::construct_trampoline_onion_packet( trampoline_payloads, trampoline_onion_keys, - prng_seed.secret_bytes(), + override_random_bytes, &payment_hash, None, ).unwrap(); // Use a different session key to construct the replacement onion packet. Note that the sender isn't aware of // this and won't be able to decode the fulfill hold times. - let outer_session_priv = secret_from_hex("e52c20461ed7acd46c4e7b591a37610519179482887bd73bf3b94617f8f03677"); - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], amt_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, outer_onion_keys, - prng_seed.secret_bytes(), + override_random_bytes, &payment_hash, ).unwrap(); From 3daab676b0be582b8b8624ebd3567e4ab4eb685b Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Mon, 25 Aug 2025 19:09:25 -0400 Subject: [PATCH 03/15] Simplify test_trampoline_single_hop_receive Previously, this test purported to test for a successful and a failing payment to a single-hop blinded path containing one trampoline node. However, to induce the failure the test was manually reconstructing the trampoline onion in a complicated way that encoded the final onion payload as a receive, when for its purposes it would be simplier for the recipient to just fail the payment backwards. In order to not regress in test coverage, the failure method the test was previously using is re-added in the next commit as a dedicated test. XXX this new test surfaced a bug that needs to be fixed --- lightning/src/ln/blinded_payment_tests.rs | 144 +++------------------- 1 file changed, 16 insertions(+), 128 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 5b987901a7a..9c66de0c0b5 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1998,7 +1998,6 @@ fn do_test_trampoline_single_hop_receive(success: bool) { connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); } - let alice_node_id = nodes[0].node().get_our_node_id(); let bob_node_id = nodes[1].node().get_our_node_id(); let carol_node_id = nodes[2].node().get_our_node_id(); @@ -2008,54 +2007,19 @@ fn do_test_trampoline_single_hop_receive(success: bool) { let amt_msat = 1000; let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); - // We need the session priv to compute the trampoline session priv and construct an invalid onion packet later. - let override_random_bytes = [3; 32]; - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); - - let outer_onion_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); - let carol_alice_trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_onion_session_priv); - let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &carol_alice_trampoline_session_priv); - let carol_blinded_hops = if success { - let payee_tlvs = UnauthenticatedReceiveTlvs { - payment_secret, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: amt_msat, - }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), - }; - - let nonce = Nonce([42u8; 16]); - let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); - let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); - let carol_unblinded_tlvs = payee_tlvs.encode(); - - let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; - blinded_path::utils::construct_blinded_hops( - &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, - ).unwrap() - } else { - let payee_tlvs = blinded_path::payment::TrampolineForwardTlvs { - next_trampoline: alice_node_id, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: amt_msat, - }, - features: BlindedHopFeatures::empty(), - payment_relay: PaymentRelay { - cltv_expiry_delta: 0, - fee_proportional_millionths: 0, - fee_base_msat: 0, - }, - next_blinding_override: None, - }; - - let carol_unblinded_tlvs = payee_tlvs.encode(); - let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; - blinded_path::utils::construct_blinded_hops( - &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, - ).unwrap() + // Create a 1-hop blinded path for Carol. + let payee_tlvs = UnauthenticatedReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; + let nonce = Nonce([42u8; 16]); + let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); + let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); + let blinded_path = BlindedPaymentPath::new(&[], carol_node_id, payee_tlvs, u64::MAX, 0, nodes[2].keys_manager, &secp_ctx).unwrap(); let route = Route { paths: vec![Path { @@ -2092,8 +2056,8 @@ fn do_test_trampoline_single_hop_receive(success: bool) { cltv_expiry_delta: 24, }, ], - hops: carol_blinded_hops, - blinding_point: carol_blinding_point, + hops: blinded_path.blinded_hops().to_vec(), + blinding_point: blinded_path.blinding_point(), excess_final_cltv_expiry_delta: 39, final_value_msat: amt_msat, }) @@ -2104,87 +2068,11 @@ fn do_test_trampoline_single_hop_receive(success: bool) { nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); check_added_monitors!(&nodes[0], 1); + pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt_msat, payment_hash, payment_secret); if success { - pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } else { - let replacement_onion = { - // create a substitute onion where the last Trampoline hop is a forward - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); - let mut blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); - - // append some dummy blinded hop so the intro hop looks like a forward - blinded_tail.hops.push(BlindedHop { - blinded_node_id: alice_node_id, - encrypted_payload: vec![], - }); - - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); - - // pop the last dummy hop - trampoline_payloads.pop(); - - let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &carol_alice_trampoline_session_priv); - let trampoline_packet = onion_utils::construct_trampoline_onion_packet( - trampoline_payloads, - trampoline_onion_keys, - override_random_bytes, - &payment_hash, - None, - ).unwrap(); - - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); - let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_onion_session_priv); - let outer_packet = onion_utils::construct_onion_packet( - outer_payloads, - outer_onion_keys, - override_random_bytes, - &payment_hash, - ).unwrap(); - - outer_packet - }; - - let mut events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - let mut first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); - let mut update_message = match first_message_event { - MessageSendEvent::UpdateHTLCs { ref mut updates, .. } => { - assert_eq!(updates.update_add_htlcs.len(), 1); - updates.update_add_htlcs.get_mut(0) - }, - _ => panic!() - }; - update_message.map(|msg| { - msg.onion_routing_packet = replacement_onion.clone(); - }); - - let route: &[&Node] = &[&nodes[1], &nodes[2]]; - let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) - .with_payment_preimage(payment_preimage) - .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::InvalidOnion); - do_pass_along_path(args); - - { - let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); - nodes[1].node.handle_update_fail_htlc( - nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] - ); - do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); - } - { - let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); - nodes[0].node.handle_update_fail_htlc( - nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] - ); - do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); - } - { - let payment_failed_conditions = PaymentFailedConditions::new() - .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionPayload, &[0; 0]); - expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); - } + fail_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_hash); } } From b6febbc7455a407c3b3dfb416186cbefc6b30b6b Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Tue, 26 Aug 2025 12:40:57 -0400 Subject: [PATCH 04/15] Test trampoline fwd payload encoded as receive This re-adds test coverage for a case that was removed in the previous commit. --- lightning/src/ln/blinded_payment_tests.rs | 188 ++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 9c66de0c0b5..f7b95b22190 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1982,6 +1982,194 @@ fn test_trampoline_inbound_payment_decoding() { }; } +#[test] +fn test_trampoline_forward_payload_encoded_as_receive() { + // Test that we'll fail backwards as expected when receiving a well-formed blinded forward + // trampoline onion payload with no next hop present. + const TOTAL_NODE_COUNT: usize = 3; + let secp_ctx = Secp256k1::new(); + + let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); + let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); + let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); + + let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks + connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); + } + + let alice_node_id = nodes[0].node().get_our_node_id(); + let bob_node_id = nodes[1].node().get_our_node_id(); + let carol_node_id = nodes[2].node().get_our_node_id(); + + let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); + let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); + + let amt_msat = 1000; + let (payment_preimage, payment_hash, _) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + + // We need the session priv to construct an invalid onion packet later. + let override_random_bytes = [3; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); + + let outer_session_priv = SecretKey::from_slice(&override_random_bytes).unwrap(); + let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); + + // Create a blinded hop for the recipient that is encoded as a trampoline forward. + let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &trampoline_session_priv); + let carol_blinded_hops = { + let payee_tlvs = blinded_path::payment::TrampolineForwardTlvs { + next_trampoline: alice_node_id, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat, + }, + features: BlindedHopFeatures::empty(), + payment_relay: PaymentRelay { + cltv_expiry_delta: 0, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + next_blinding_override: None, + }; + + let carol_unblinded_tlvs = payee_tlvs.encode(); + let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; + blinded_path::utils::construct_blinded_hops( + &secp_ctx, path.into_iter(), &trampoline_session_priv, + ).unwrap() + }; + + let route = Route { + paths: vec![Path { + hops: vec![ + // Bob + RouteHop { + pubkey: bob_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: alice_bob_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: bob_carol_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 0, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + } + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol + TrampolineHop { + pubkey: carol_node_id, + node_features: Features::empty(), + fee_msat: amt_msat, + cltv_expiry_delta: 24, + }, + ], + hops: carol_blinded_hops, + blinding_point: carol_blinding_point, + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + check_added_monitors!(&nodes[0], 1); + + let replacement_onion = { + // create a substitute onion where the last Trampoline hop is a forward + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + + let mut blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); + + // append some dummy blinded hop so the intro hop looks like a forward + blinded_tail.hops.push(BlindedHop { + blinded_node_id: alice_node_id, + encrypted_payload: vec![], + }); + + let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + + // pop the last dummy hop + trampoline_payloads.pop(); + + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_session_priv); + let trampoline_packet = onion_utils::construct_trampoline_onion_packet( + trampoline_payloads, + trampoline_onion_keys, + override_random_bytes, + &payment_hash, + None, + ).unwrap(); + + let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); + let outer_packet = onion_utils::construct_onion_packet( + outer_payloads, + outer_onion_keys, + override_random_bytes, + &payment_hash, + ).unwrap(); + + outer_packet + }; + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let mut first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let mut update_message = match first_message_event { + MessageSendEvent::UpdateHTLCs { ref mut updates, .. } => { + assert_eq!(updates.update_add_htlcs.len(), 1); + updates.update_add_htlcs.get_mut(0) + }, + _ => panic!() + }; + update_message.map(|msg| { + msg.onion_routing_packet = replacement_onion.clone(); + }); + + let route: &[&Node] = &[&nodes[1], &nodes[2]]; + let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) + .with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::InvalidOnion); + do_pass_along_path(args); + + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } + { + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionPayload, &[0; 0]); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + } +} + fn do_test_trampoline_single_hop_receive(success: bool) { const TOTAL_NODE_COUNT: usize = 3; let secp_ctx = Secp256k1::new(); From 35a0eb58eba0c4bdae49918d7f10d8f09b868b74 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 27 Aug 2025 14:41:25 -0400 Subject: [PATCH 05/15] f: fix failing test from 3daab676b0be582b8b8624ebd3567e4ab4eb685b --- lightning/src/ln/blinded_payment_tests.rs | 2 +- lightning/src/ln/onion_utils.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index f7b95b22190..71f4c679af4 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2535,6 +2535,6 @@ fn test_trampoline_forward_rejection() { // Expect UnknownNextPeer error while we are unable to route forwarding Trampoline payments. let payment_failed_conditions = PaymentFailedConditions::new() .expected_htlc_error_data(LocalHTLCFailureReason::UnknownNextPeer, &[0; 0]); - expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); } } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 0bcfbce57cb..bdadcd0ef12 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1373,10 +1373,17 @@ where short_channel_id = route_hop.short_channel_id() } + // If next hop is from a trampoline we are already inside the trampoline + // route. If we found a permanent failure inside the trampoline route, we + // fail the payment permanently. + let is_next_hop_from_trampoline = + matches!(next_hop, Some((_, (Some(ErrorHop::TrampolineHop(..)), _)))); + res = Some(FailureLearnings { network_update, short_channel_id, - payment_failed_permanently: error_code.is_permanent() && is_from_final_non_blinded_node, + payment_failed_permanently: error_code.is_permanent() + && (is_from_final_non_blinded_node || is_next_hop_from_trampoline), failed_within_blinded_path: false, }); From e0b0d253babc97530d8e74089524bcc105495ac1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 14 Aug 2025 15:00:03 -0400 Subject: [PATCH 06/15] Add trampoline `LocalHTLCFailureReason` variants per spec This commit adds three new local htlc failure error reasons: `TemporaryTrampolineFailure`, `TrampolineFeeOrExpiryInsufficient`, and `UnknownNextTrampoline` for trampoline payment forwarding failures. --- lightning/src/ln/onion_utils.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index bdadcd0ef12..335f4dca2a5 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1668,6 +1668,13 @@ pub enum LocalHTLCFailureReason { HTLCMaximum, /// The HTLC was failed because our remote peer is offline. PeerOffline, + /// We have been unable to forward a payment to the next Trampoline node but may be able to + /// do it later. + TemporaryTrampolineFailure, + /// The amount or CLTV expiry were insufficient to route the payment to the next Trampoline. + TrampolineFeeOrExpiryInsufficient, + /// The specified next Trampoline node cannot be reached from our node. + UnknownNextTrampoline, } impl LocalHTLCFailureReason { @@ -1708,6 +1715,9 @@ impl LocalHTLCFailureReason { Self::InvalidOnionPayload | Self::InvalidTrampolinePayload => PERM | 22, Self::MPPTimeout => 23, Self::InvalidOnionBlinding => BADONION | PERM | 24, + Self::TemporaryTrampolineFailure => NODE | 25, + Self::TrampolineFeeOrExpiryInsufficient => NODE | 26, + Self::UnknownNextTrampoline => PERM | 27, Self::UnknownFailureCode { code } => *code, } } @@ -1842,6 +1852,9 @@ impl_writeable_tlv_based_enum!(LocalHTLCFailureReason, (79, HTLCMinimum) => {}, (81, HTLCMaximum) => {}, (83, PeerOffline) => {}, + (85, TemporaryTrampolineFailure) => {}, + (87, TrampolineFeeOrExpiryInsufficient) => {}, + (89, UnknownNextTrampoline) => {}, ); impl From<&HTLCFailReason> for HTLCHandlingFailureReason { @@ -2008,6 +2021,11 @@ impl HTLCFailReason { debug_assert!(false, "Unknown failure code: {}", code) } }, + LocalHTLCFailureReason::TemporaryTrampolineFailure => debug_assert!(data.is_empty()), + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient => { + debug_assert_eq!(data.len(), 10) + }, + LocalHTLCFailureReason::UnknownNextTrampoline => debug_assert!(data.is_empty()), } Self(HTLCFailReasonRepr::Reason { data, failure_reason }) From cfbade28cd7ad13068fb393df43c530f23d0f961 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 14 Aug 2025 15:01:31 -0400 Subject: [PATCH 07/15] Enforce Trampoline constraints We add a `check_trampoline_constraints` similar to `check_blinded_path_constraints` that compares the Trampoline onion's amount and CLTV values to the limitations imposed by the outer onion. Also, we add and modify the following tests: - Modified the unblinded receive to validate when receiving amount less than expected. - Modified test with wrong CLTV parameters that now fails with new enforcement of CLTV limits. - Add unblinded and blinded receive tests that forces trampoline onion's CLTV to be greater than the outer onion packet. Note that there are some TODOs to be fixed in following commits as we need the full trampoline forwarding feature to effectively test all cases. Co-authored-by: Arik Sosman --- lightning/src/ln/blinded_payment_tests.rs | 451 +++++++++++++++++++++- lightning/src/ln/onion_payment.rs | 79 +++- 2 files changed, 512 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 71f4c679af4..2ea66e1e93a 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2075,7 +2075,7 @@ fn test_trampoline_forward_payload_encoded_as_receive() { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 104, }, ], hops: carol_blinded_hops, @@ -2241,7 +2241,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 104, }, ], hops: blinded_path.blinded_hops().to_vec(), @@ -2273,9 +2273,13 @@ fn test_trampoline_single_hop_receive() { do_test_trampoline_single_hop_receive(false); } -#[test] -fn test_trampoline_unblinded_receive() { - // Simulate a payment of A (0) -> B (1) -> C(Trampoline) (2) +fn do_test_trampoline_unblinded_receive(underpay: bool) { + // Test trampoline payment receipt with unblinded final hop. + // Creates custom onion packet where the final trampoline hop uses unblinded receive format + // (not natively supported) to validate payment amount verification. + // - When underpay=false: Payment succeeds with correct amount + // - When underpay=true: Payment fails due to amount mismatch (sends 1/2 expected amount) + // Topology: A (0) -> B (1) C -> (Trampoline receiver) (2) const TOTAL_NODE_COUNT: usize = 3; let secp_ctx = Secp256k1::new(); @@ -2321,7 +2325,7 @@ fn test_trampoline_unblinded_receive() { node_features: NodeFeatures::empty(), short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), - fee_msat: 0, + fee_msat: 0, // no routing fees because it's the final hop cltv_expiry_delta: 48, maybe_announced_channel: false, } @@ -2332,8 +2336,8 @@ fn test_trampoline_unblinded_receive() { TrampolineHop { pubkey: carol_node_id, node_features: Features::empty(), - fee_msat: amt_msat, - cltv_expiry_delta: 24, + fee_msat: 0, // no trampoline fee because we are receiving. + cltv_expiry_delta: 72, // blinded hop cltv to be used building the outer onion. }, ], // The blinded path data is unused because we replace the onion of the last hop @@ -2362,13 +2366,15 @@ fn test_trampoline_unblinded_receive() { let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); - let (_, _, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + let (_, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + let replacement_payload_amount = if underpay { amt_msat * 2 } else { amt_msat }; let trampoline_payloads = vec![msgs::OutboundTrampolinePayload::Receive { payment_data: Some(msgs::FinalOnionHopData { payment_secret, - total_msat: amt_msat, + total_msat: replacement_payload_amount, }), - sender_intended_htlc_amt_msat: amt_msat, + sender_intended_htlc_amt_msat: replacement_payload_amount, + // We will use the same cltv to the outer onion: 72 (blinded tail) + 32 (offset). cltv_expiry_height: 104, }]; @@ -2383,8 +2389,236 @@ fn test_trampoline_unblinded_receive() { // Use a different session key to construct the replacement onion packet. Note that the sender isn't aware of // this and won't be able to decode the fulfill hold times. + let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); + let outer_packet = onion_utils::construct_onion_packet( + outer_payloads, + outer_onion_keys, + override_random_bytes, + &payment_hash, + ).unwrap(); + + outer_packet + }; + + check_added_monitors!(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let mut first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let mut update_message = match first_message_event { + MessageSendEvent::UpdateHTLCs { ref mut updates, .. } => { + assert_eq!(updates.update_add_htlcs.len(), 1); + updates.update_add_htlcs.get_mut(0) + }, + _ => panic!() + }; + update_message.map(|msg| { + msg.onion_routing_packet = replacement_onion.clone(); + }); + + let route: &[&Node] = &[&nodes[1], &nodes[2]]; + let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event); + + let args = if underpay { + args.with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + } else { + args.with_payment_secret(payment_secret) + }; + + do_pass_along_path(args); + + if underpay { + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } + { + let expected_error_data = amt_msat.to_be_bytes(); + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::FinalIncorrectHTLCAmount, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + } + } else { + claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); + } +} + +#[test] +fn test_trampoline_unblinded_receive_underpay() { + do_test_trampoline_unblinded_receive(true); +} + +#[test] +fn test_trampoline_unblinded_receive_normal() { + do_test_trampoline_unblinded_receive(false); +} + +#[derive(PartialEq)] +enum TrampolineConstraintFailureScenarios { + TrampolineCLTVGreaterThanOnion, + #[allow(dead_code)] + // TODO: To test amount greater than onion we need the ability + // to forward Trampoline payments. + TrampolineAmountGreaterThanOnion, +} + +fn do_test_trampoline_unblinded_receive_constraint_failure(failure_scenario: TrampolineConstraintFailureScenarios) { + // Test trampoline payment constraint validation failures with unblinded receive format. + // Creates deliberately invalid trampoline payments to verify constraint enforcement: + // - TrampolineCLTVGreaterThanOnion: Trampoline CLTV exceeds outer onion requirements + // - TrampolineAmountGreaterThanOnion: Trampoline amount exceeds outer onion value + // Uses custom onion construction to simulate constraint violations that should trigger + // specific HTLC failure codes (FinalIncorrectCLTVExpiry or FinalIncorrectHTLCAmount). + // Topology: A (0) -> B (1) -> C (Trampoline receiver) (2) + + const TOTAL_NODE_COUNT: usize = 3; + let secp_ctx = Secp256k1::new(); + + let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); + let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); + let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); + + let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks + connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); + } + + let alice_node_id = nodes[0].node().get_our_node_id(); + let bob_node_id = nodes[1].node().get_our_node_id(); + let carol_node_id = nodes[2].node().get_our_node_id(); + + let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); + let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); + + let amt_msat = 1000; + let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + let payee_tlvs = blinded_path::payment::TrampolineForwardTlvs { + next_trampoline: alice_node_id, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat, + }, + features: BlindedHopFeatures::empty(), + payment_relay: PaymentRelay { + cltv_expiry_delta: 0, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + next_blinding_override: None, + }; + + let carol_unblinded_tlvs = payee_tlvs.encode(); + let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; + let carol_alice_trampoline_session_priv = secret_from_hex("a0f4b8d7b6c2d0ffdfaf718f76e9decaef4d9fb38a8c4addb95c4007cc3eee03"); + let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &carol_alice_trampoline_session_priv); + let carol_blinded_hops = blinded_path::utils::construct_blinded_hops( + &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, + ).unwrap(); + + // We decide an arbitrary ctlv delta for the blinded hop that will be the only cltv delta + // in the blinded tail. + let blinded_hop_cltv = if failure_scenario == TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion { 52 } else { 72 }; + // Then when building the trampoline hop we use an arbitrary cltv delta offset to be used + // when re-building the outer trampoline onion. + let starting_cltv_offset_trampoline = 32; + // Finally we decide a forced cltv delta expiry for the trampoline hop itself. + // This one will be compared against the outer onion ctlv delta. + let forced_trampoline_cltv_delta = 104; + let route = Route { + paths: vec![Path { + hops: vec![ + // Bob + RouteHop { + pubkey: bob_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: alice_bob_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: bob_carol_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 0, // no routing fees because it's the final hop + cltv_expiry_delta: 48, + maybe_announced_channel: false, + } + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol + TrampolineHop { + pubkey: carol_node_id, + node_features: Features::empty(), + fee_msat: amt_msat, + cltv_expiry_delta: blinded_hop_cltv, // blinded tail ctlv delta. + }, + ], + hops: carol_blinded_hops, + blinding_point: carol_blinding_point, + // This will be ignored because we force the cltv_expiry of the trampoline hop. + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + + let override_random_bytes = [42; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); + + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + + let replacement_onion = { + // create a substitute onion where the last Trampoline hop is an unblinded receive, which we + // (deliberately) do not support out of the box, therefore necessitating this workaround + let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); + let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + + let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], amt_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let (_ , outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, starting_cltv_offset_trampoline, &None).unwrap(); + let trampoline_payloads = vec![msgs::OutboundTrampolinePayload::Receive { + payment_data: Some(msgs::FinalOnionHopData { + payment_secret, + total_msat: amt_msat, + }), + sender_intended_htlc_amt_msat: amt_msat, + cltv_expiry_height: forced_trampoline_cltv_delta, + }]; + + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_session_priv); + let trampoline_packet = onion_utils::construct_trampoline_onion_packet( + trampoline_payloads, + trampoline_onion_keys, + override_random_bytes, + &payment_hash, + None, + ).unwrap(); + + let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, @@ -2414,10 +2648,199 @@ fn test_trampoline_unblinded_receive() { let route: &[&Node] = &[&nodes[1], &nodes[2]]; let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) - .with_payment_secret(payment_secret); + .with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + do_pass_along_path(args); + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } - claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); + match failure_scenario { + TrampolineConstraintFailureScenarios::TrampolineAmountGreaterThanOnion => { + let expected_error_data = amt_msat.to_be_bytes(); + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::FinalIncorrectHTLCAmount, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + }, + TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion => { + // The amount of the outer onion cltv delta plus the trampoline offset. + let expected_error_data = (blinded_hop_cltv + starting_cltv_offset_trampoline).to_be_bytes(); + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::FinalIncorrectCLTVExpiry, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + } + } +} + +fn do_test_trampoline_blinded_receive_constraint_failure(failure_scenario: TrampolineConstraintFailureScenarios) { + // Test trampoline payment constraint validation failures with blinded receive format. + // Creates deliberately invalid trampoline payments to verify constraint enforcement: + // - TrampolineCLTVGreaterThanOnion: Trampoline CLTV exceeds outer onion requirements + // - TrampolineAmountGreaterThanOnion: Trampoline amount exceeds outer onion value + // Topology: A (0) -> B (1) -> C (Trampoline receiver inside blinded path) (2) + + const TOTAL_NODE_COUNT: usize = 3; + let secp_ctx = Secp256k1::new(); + + let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); + let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); + let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); + + let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks + connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); + } + + let bob_node_id = nodes[1].node().get_our_node_id(); + let carol_node_id = nodes[2].node().get_our_node_id(); + + let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); + let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); + let amt_msat = 1000; + let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + + let alice_carol_trampoline_shared_secret = secret_from_hex("a0f4b8d7b6c2d0ffdfaf718f76e9decaef4d9fb38a8c4addb95c4007cc3eee03"); + let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &alice_carol_trampoline_shared_secret); + let payee_tlvs = UnauthenticatedReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }; + + let nonce = Nonce([42u8; 16]); + let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); + let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); + let carol_unblinded_tlvs = payee_tlvs.encode(); + + // Blinded path is Carol as recipient. + let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; + let blinded_hops = blinded_path::utils::construct_blinded_hops( + &secp_ctx, path.into_iter(), &alice_carol_trampoline_shared_secret, + ).unwrap(); + + // We decide an arbitrary ctlv delta for the blinded hop that will be the only cltv delta + // in the blinded tail. + let blinded_hop_cltv = if failure_scenario == TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion { 2 } else { 144 }; + + let route = Route { + paths: vec![Path { + hops: vec![ + // Bob + RouteHop { + pubkey: bob_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: alice_bob_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, // forwarding fee to Carol + cltv_expiry_delta: 48, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: bob_carol_scid, + channel_features: ChannelFeatures::empty(), + // fee for the usage of the entire blinded path, including Trampoline. + // In this case is zero as we are the recipient of the payment. + fee_msat: 0, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + } + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol + TrampolineHop { + pubkey: carol_node_id, + node_features: Features::empty(), + fee_msat: amt_msat, + cltv_expiry_delta: blinded_hop_cltv, + }, + + ], + hops: blinded_hops, + blinding_point: carol_blinding_point, + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + check_added_monitors!(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + + let route: &[&Node] = &[&nodes[1], &nodes[2]]; + let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) + .with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + + do_pass_along_path(args); + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } + + // We don't share the error data when receiving inside a blinded path. + let expected_error_data = [0; 32]; + match failure_scenario { + TrampolineConstraintFailureScenarios::TrampolineAmountGreaterThanOnion => { + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionBlinding, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + }, + TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion => { + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionBlinding, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + } + } +} + +#[test] +fn test_trampoline_enforced_constraint_cltv() { + do_test_trampoline_unblinded_receive_constraint_failure(TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion); +} + +#[test] +fn test_trampoline_blinded_receive_enforced_constraint_cltv() { + do_test_trampoline_blinded_receive_constraint_failure(TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion); } #[test] diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 79952faca9a..474c7017e2f 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -74,6 +74,34 @@ fn check_blinded_forward( Ok((amt_to_forward, outgoing_cltv_value)) } +fn check_trampoline_onion_constraints( + outer_hop_data: &msgs::InboundTrampolineEntrypointPayload, trampoline_cltv_value: u32, + trampoline_amount: u64, +) -> Result<(), InboundHTLCErr> { + if outer_hop_data.outgoing_cltv_value < trampoline_cltv_value { + let err = InboundHTLCErr { + reason: LocalHTLCFailureReason::FinalIncorrectCLTVExpiry, + err_data: outer_hop_data.outgoing_cltv_value.to_be_bytes().to_vec(), + msg: "Trampoline onion's CLTV value exceeded the outer onion's", + }; + return Err(err); + } + let outgoing_amount = outer_hop_data + .multipath_trampoline_data + .as_ref() + .map_or(outer_hop_data.amt_to_forward, |mtd| mtd.total_msat); + if outgoing_amount < trampoline_amount { + let err = InboundHTLCErr { + reason: LocalHTLCFailureReason::FinalIncorrectHTLCAmount, + err_data: outgoing_amount.to_be_bytes().to_vec(), + msg: "Trampoline onion's amt value exceeded the outer onion's", + }; + return Err(err); + } + + Ok(()) +} + enum RoutingInfo { Direct { short_channel_id: u64, @@ -135,7 +163,9 @@ pub(super) fn create_fwd_pending_htlc_info( reason: LocalHTLCFailureReason::InvalidOnionPayload, err_data: Vec::new(), }), - onion_utils::Hop::TrampolineForward { next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + onion_utils::Hop::TrampolineForward { ref outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + // TODO: return reason as forward issue, not as receiving issue when forwarding is ready. + check_trampoline_onion_constraints(outer_hop_data, next_trampoline_hop_data.outgoing_cltv_value, next_trampoline_hop_data.amt_to_forward)?; ( RoutingInfo::Trampoline { next_trampoline: next_trampoline_hop_data.next_trampoline, @@ -150,7 +180,7 @@ pub(super) fn create_fwd_pending_htlc_info( None ) }, - onion_utils::Hop::TrampolineBlindedForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + onion_utils::Hop::TrampolineBlindedForward { ref outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( msg.amount_msat, msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { @@ -162,6 +192,15 @@ pub(super) fn create_fwd_pending_htlc_info( err_data: vec![0; 32], } })?; + check_trampoline_onion_constraints(outer_hop_data, outgoing_cltv_value, amt_to_forward).map_err(|e| { + // The Trampoline onion's amt and CLTV values cannot exceed the outer onion's, but + // we're inside a blinded path + InboundHTLCErr { + reason: LocalHTLCFailureReason::InvalidOnionBlinding, + err_data: vec![0; 32], + msg: e.msg, + } + })?; ( RoutingInfo::Trampoline { next_trampoline: next_trampoline_hop_data.next_trampoline, @@ -281,14 +320,18 @@ pub(super) fn create_recv_pending_htlc_info( intro_node_blinding_point.is_none(), true, invoice_request) } onion_utils::Hop::TrampolineReceive { + ref outer_hop_data, trampoline_hop_data: msgs::InboundOnionReceivePayload { payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat, cltv_expiry_height, payment_metadata, .. }, .. - } => + } => { + check_trampoline_onion_constraints(outer_hop_data, cltv_expiry_height, sender_intended_htlc_amt_msat)?; (payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat, - cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None), + cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None) + } onion_utils::Hop::TrampolineBlindedReceive { + ref outer_hop_data, trampoline_hop_data: msgs::InboundOnionBlindedReceivePayload { sender_intended_htlc_amt_msat, total_msat, cltv_expiry_height, payment_secret, intro_node_blinding_point, payment_constraints, payment_context, keysend_preimage, @@ -306,6 +349,15 @@ pub(super) fn create_recv_pending_htlc_info( } })?; let payment_data = msgs::FinalOnionHopData { payment_secret, total_msat }; + check_trampoline_onion_constraints(outer_hop_data, cltv_expiry_height, sender_intended_htlc_amt_msat).map_err(|e| { + // The Trampoline onion's amt and CLTV values cannot exceed the outer onion's, but + // we're inside a blinded path + InboundHTLCErr { + reason: LocalHTLCFailureReason::InvalidOnionBlinding, + err_data: vec![0; 32], + msg: e.msg, + } + })?; (Some(payment_data), keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat, cltv_expiry_height, None, Some(payment_context), intro_node_blinding_point.is_none(), true, invoice_request) @@ -602,6 +654,25 @@ where outgoing_cltv_value, }) } + onion_utils::Hop::TrampolineBlindedForward { next_trampoline_hop_data: msgs::InboundTrampolineBlindedForwardPayload { next_trampoline, ref payment_relay, ref payment_constraints, ref features, .. }, outer_shared_secret, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { + let (amt_to_forward, outgoing_cltv_value) = match check_blinded_forward( + msg.amount_msat, msg.cltv_expiry, &payment_relay, &payment_constraints, &features + ) { + Ok((amt, cltv)) => (amt, cltv), + Err(()) => { + return encode_relay_error("Trampoline blinded forward amt or CLTV values exceeded the outer onion's", + LocalHTLCFailureReason::InvalidOnionBlinding, outer_shared_secret.secret_bytes(), Some(trampoline_shared_secret.secret_bytes()), &[0; 32]); + } + }; + let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, + incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes()); + Some(NextPacketDetails { + next_packet_pubkey: next_trampoline_packet_pubkey, + outgoing_connector: HopConnector::Trampoline(next_trampoline), + outgoing_amt_msat: amt_to_forward, + outgoing_cltv_value, + }) + } _ => None }; From df49139c7b0a40813fa2e6a9853a980878955eb9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 22 Aug 2025 10:37:21 -0400 Subject: [PATCH 08/15] Add TrampolineForward variant to HTLCSource enum Add new HTLCSource::TrampolineForward variant to track trampoline routing information. Implement hash trait and serialization for the new variant. --- lightning/src/chain/channelmonitor.rs | 2 + lightning/src/ln/channelmanager.rs | 62 +++++++++++++++++++++++++++ lightning/src/util/ser.rs | 2 + 3 files changed, 66 insertions(+) diff --git a/lightning/src/chain/channelmonitor.rs b/lightning/src/chain/channelmonitor.rs index d46cfaa636c..5b81629127f 100644 --- a/lightning/src/chain/channelmonitor.rs +++ b/lightning/src/chain/channelmonitor.rs @@ -2726,6 +2726,7 @@ impl ChannelMonitorImpl { let outbound_payment = match source { None => panic!("Outbound HTLCs should have a source"), Some(&HTLCSource::PreviousHopData(_)) => false, + Some(&HTLCSource::TrampolineForward { .. }) => false, Some(&HTLCSource::OutboundRoute { .. }) => true, }; return Some(Balance::MaybeTimeoutClaimableHTLC { @@ -2935,6 +2936,7 @@ impl ChannelMonitor { let outbound_payment = match source { None => panic!("Outbound HTLCs should have a source"), Some(HTLCSource::PreviousHopData(_)) => false, + Some(&HTLCSource::TrampolineForward { .. }) => false, Some(HTLCSource::OutboundRoute { .. }) => true, }; if outbound_payment { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c953e39d6d6..57135c0b0e7 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -646,6 +646,7 @@ impl SentHTLCId { short_channel_id: hop_data.short_channel_id, htlc_id: hop_data.htlc_id, }, + HTLCSource::TrampolineForward { .. } => todo!(), HTLCSource::OutboundRoute { session_priv, .. } => { Self::OutboundRoute { session_priv: session_priv.secret_bytes() } }, @@ -669,6 +670,8 @@ type PerSourcePendingForward = type FailedHTLCForward = (HTLCSource, PaymentHash, HTLCFailReason, HTLCHandlingFailureType); mod fuzzy_channelmanager { + use crate::routing::router::RouteHop; + use super::*; /// Tracks the inbound corresponding to an outbound HTLC @@ -676,6 +679,16 @@ mod fuzzy_channelmanager { #[derive(Clone, Debug, PartialEq, Eq)] pub enum HTLCSource { PreviousHopData(HTLCPreviousHopData), + TrampolineForward { + /// We might be forwarding an incoming payment that was received over MPP, and therefore + /// need to store the vector of corresponding `HTLCPreviousHopId` values. + previous_hop_data: Vec, + incoming_trampoline_shared_secret: [u8; 32], + hops: Vec, + /// In order to decode inter-Trampoline errors, we need to store the session_priv key + /// given we're effectively creating new outbound routes. + session_priv: SecretKey, + }, OutboundRoute { path: Path, session_priv: SecretKey, @@ -738,6 +751,18 @@ impl core::hash::Hash for HTLCSource { first_hop_htlc_msat.hash(hasher); bolt12_invoice.hash(hasher); }, + HTLCSource::TrampolineForward { + previous_hop_data, + incoming_trampoline_shared_secret, + hops, + session_priv, + } => { + 2u8.hash(hasher); + previous_hop_data.hash(hasher); + incoming_trampoline_shared_secret.hash(hasher); + hops.hash(hasher); + session_priv[..].hash(hasher); + }, } } } @@ -8050,6 +8075,7 @@ where None, )); }, + HTLCSource::TrampolineForward { .. } => todo!(), } } @@ -8767,6 +8793,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ }, ); }, + HTLCSource::TrampolineForward { .. } => todo!(), } } @@ -15064,6 +15091,24 @@ impl Readable for HTLCSource { }) } 1 => Ok(HTLCSource::PreviousHopData(Readable::read(reader)?)), + 2 => { + let mut previous_hop_data = Vec::new(); + let mut incoming_trampoline_shared_secret: crate::util::ser::RequiredWrapper<[u8; 32]> = crate::util::ser::RequiredWrapper(None); + let mut session_priv: crate::util::ser::RequiredWrapper = crate::util::ser::RequiredWrapper(None); + let mut hops = Vec::new(); + read_tlv_fields!(reader, { + (0, previous_hop_data, required_vec), + (2, incoming_trampoline_shared_secret, required), + (4, session_priv, required), + (6, hops, required_vec), + }); + Ok(HTLCSource::TrampolineForward { + previous_hop_data, + incoming_trampoline_shared_secret: incoming_trampoline_shared_secret.0.unwrap(), + hops, + session_priv: session_priv.0.unwrap(), + }) + }, _ => Err(DecodeError::UnknownRequiredFeature), } } @@ -15096,6 +15141,22 @@ impl Writeable for HTLCSource { 1u8.write(writer)?; field.write(writer)?; }, + HTLCSource::TrampolineForward { + previous_hop_data: previous_hop_data_ref, + ref incoming_trampoline_shared_secret, + ref session_priv, + hops: hops_ref, + } => { + 2u8.write(writer)?; + let previous_hop_data = previous_hop_data_ref.clone(); + let hops = hops_ref.clone(); + write_tlv_fields!(writer, { + (0, previous_hop_data, required_vec), + (2, incoming_trampoline_shared_secret, required), + (4, session_priv, required), + (6, hops, required_vec), + }); + }, } Ok(()) } @@ -16539,6 +16600,7 @@ where } else { true } }); }, + HTLCSource::TrampolineForward { .. } => todo!(), HTLCSource::OutboundRoute { payment_id, session_priv, diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index b2521d93109..31697b4f34d 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1077,6 +1077,7 @@ impl Readable for Vec { impl_for_vec!(ecdsa::Signature); impl_for_vec!(crate::chain::channelmonitor::ChannelMonitorUpdate); +impl_for_vec!(crate::ln::channelmanager::HTLCPreviousHopData); impl_for_vec!(crate::ln::channelmanager::MonitorUpdateCompletionAction); impl_for_vec!(crate::ln::channelmanager::PaymentClaimDetails); impl_for_vec!(crate::ln::msgs::SocketAddress); @@ -1087,6 +1088,7 @@ impl_for_vec!(InteractiveTxOutput); impl_for_vec!(crate::ln::our_peer_storage::PeerStorageMonitorHolder); impl_writeable_for_vec!(&crate::routing::router::BlindedTail); impl_readable_for_vec!(crate::routing::router::BlindedTail); +impl_for_vec!(crate::routing::router::RouteHop); impl_for_vec!(crate::routing::router::TrampolineHop); impl_for_vec_with_element_length_prefix!(crate::ln::msgs::UpdateAddHTLC); impl_writeable_for_vec_with_element_length_prefix!(&crate::ln::msgs::UpdateAddHTLC); From 01e37420447242b019fcf41a9f07d8669dc1cd0e Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 13:13:39 -0400 Subject: [PATCH 09/15] Extract common HTLC failure handling into helper methods In order to reduce code duplication for trampoline routing support, this commit extracts the following heper methods. - `get_htlc_failure_from_blinded_failure_forward`: builds htlc forward info with error details. - `fail_htlc_backwards_from_forward`: handles failure propagation. --- lightning/src/ln/channelmanager.rs | 116 ++++++++++++++++++----------- 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 57135c0b0e7..cfa9a1f4233 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8029,56 +8029,82 @@ where &payment_hash, onion_error ); - let failure = match blinded_failure { - Some(BlindedFailure::FromIntroductionNode) => { - let blinded_onion_error = HTLCFailReason::reason( - LocalHTLCFailureReason::InvalidOnionBlinding, - vec![0; 32], - ); - let err_packet = blinded_onion_error.get_encrypted_failure_packet( - incoming_packet_shared_secret, - phantom_shared_secret, - ); - HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet } - }, - Some(BlindedFailure::FromBlindedNode) => HTLCForwardInfo::FailMalformedHTLC { - htlc_id: *htlc_id, - failure_code: LocalHTLCFailureReason::InvalidOnionBlinding.failure_code(), - sha256_of_onion: [0; 32], - }, - None => { - let err_packet = onion_error.get_encrypted_failure_packet( - incoming_packet_shared_secret, - phantom_shared_secret, - ); - HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet } - }, - }; - - let mut forward_htlcs = self.forward_htlcs.lock().unwrap(); - match forward_htlcs.entry(*short_channel_id) { - hash_map::Entry::Occupied(mut entry) => { - entry.get_mut().push(failure); - }, - hash_map::Entry::Vacant(entry) => { - entry.insert(vec![failure]); - }, - } - mem::drop(forward_htlcs); - let mut pending_events = self.pending_events.lock().unwrap(); - pending_events.push_back(( - events::Event::HTLCHandlingFailed { - prev_channel_id: *channel_id, - failure_type, - failure_reason: Some(onion_error.into()), - }, - None, - )); + let htlc_failure_reason = self.get_htlc_failure_from_blinded_failure_forward( + blinded_failure, + onion_error, + incoming_packet_shared_secret, + phantom_shared_secret, + htlc_id, + ); + self.fail_htlc_backwards_from_forward( + &onion_error, + failure_type, + short_channel_id, + channel_id, + htlc_failure_reason, + ); }, HTLCSource::TrampolineForward { .. } => todo!(), } } + fn get_htlc_failure_from_blinded_failure_forward( + &self, blinded_failure: &Option, onion_error: &HTLCFailReason, + incoming_packet_shared_secret: &[u8; 32], secondary_shared_secret: &Option<[u8; 32]>, + htlc_id: &u64, + ) -> HTLCForwardInfo { + match blinded_failure { + Some(BlindedFailure::FromIntroductionNode) => { + let blinded_onion_error = HTLCFailReason::reason( + LocalHTLCFailureReason::InvalidOnionBlinding, + vec![0; 32], + ); + let err_packet = blinded_onion_error.get_encrypted_failure_packet( + incoming_packet_shared_secret, + secondary_shared_secret, + ); + HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet } + }, + Some(BlindedFailure::FromBlindedNode) => HTLCForwardInfo::FailMalformedHTLC { + htlc_id: *htlc_id, + failure_code: LocalHTLCFailureReason::InvalidOnionBlinding.failure_code(), + sha256_of_onion: [0; 32], + }, + None => { + let err_packet = onion_error.get_encrypted_failure_packet( + incoming_packet_shared_secret, + secondary_shared_secret, + ); + HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet } + }, + } + } + + fn fail_htlc_backwards_from_forward( + &self, onion_error: &HTLCFailReason, failure_type: HTLCHandlingFailureType, + short_channel_id: &u64, channel_id: &ChannelId, failure: HTLCForwardInfo, + ) { + let mut forward_htlcs = self.forward_htlcs.lock().unwrap(); + match forward_htlcs.entry(*short_channel_id) { + hash_map::Entry::Occupied(mut entry) => { + entry.get_mut().push(failure); + }, + hash_map::Entry::Vacant(entry) => { + entry.insert(vec![failure]); + }, + } + mem::drop(forward_htlcs); + let mut pending_events = self.pending_events.lock().unwrap(); + pending_events.push_back(( + events::Event::HTLCHandlingFailed { + prev_channel_id: *channel_id, + failure_type, + failure_reason: Some(onion_error.into()), + }, + None, + )); + } + /// Provides a payment preimage in response to [`Event::PaymentClaimable`], generating any /// [`MessageSendEvent`]s needed to claim the payment. /// From fbe7626f64aa554e1aa46a8e600341df8fed3718 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 13:35:33 -0400 Subject: [PATCH 10/15] Extract claiming funds from previous hop to helper method Move `HTLCSource::PreviousHopData` claiming logic into `claim_funds_from_previous_hop_internal` to prepare for trampoline routing reuse. --- lightning/src/ln/channelmanager.rs | 271 ++++++++++++++++------------- 1 file changed, 148 insertions(+), 123 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index cfa9a1f4233..cbb778c3e20 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8688,141 +8688,166 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ &self.logger, ); }, - HTLCSource::PreviousHopData(hop_data) => { - let prev_channel_id = hop_data.channel_id; - let prev_user_channel_id = hop_data.user_channel_id; - let prev_node_id = hop_data.counterparty_node_id; - let completed_blocker = - RAAMonitorUpdateBlockingAction::from_prev_hop_data(&hop_data); - - // Obtain hold time, if available. - let hold_time = hold_time_since(send_timestamp).unwrap_or(0); - - // If attribution data was received from downstream, we shift it and get it ready for adding our hold - // time. Note that fulfilled HTLCs take a fast path to the incoming side. We don't need to wait for RAA - // to record the hold time like we do for failed HTLCs. - let attribution_data = process_fulfill_attribution_data( - attribution_data, - &hop_data.incoming_packet_shared_secret, - hold_time, - ); + HTLCSource::PreviousHopData(hop_data) => self.claim_funds_from_previous_hop_internal( + payment_preimage, + forwarded_htlc_value_msat, + skimmed_fee_msat, + from_onchain, + startup_replay, + next_channel_counterparty_node_id, + next_channel_outpoint, + next_channel_id, + next_user_channel_id, + hop_data, + attribution_data, + send_timestamp, + ), + HTLCSource::TrampolineForward { .. } => todo!(), + } + } - #[cfg(test)] - let claiming_chan_funding_outpoint = hop_data.outpoint; - self.claim_funds_from_hop( - hop_data, - payment_preimage, - None, - Some(attribution_data), - |htlc_claim_value_msat, definitely_duplicate| { - let chan_to_release = Some(EventUnblockedChannel { - counterparty_node_id: next_channel_counterparty_node_id, - funding_txo: next_channel_outpoint, - channel_id: next_channel_id, - blocking_action: completed_blocker, - }); + fn claim_funds_from_previous_hop_internal( + &self, payment_preimage: PaymentPreimage, forwarded_htlc_value_msat: Option, + skimmed_fee_msat: Option, from_onchain: bool, startup_replay: bool, + next_channel_counterparty_node_id: PublicKey, next_channel_outpoint: OutPoint, + next_channel_id: ChannelId, next_user_channel_id: Option, + hop_data: HTLCPreviousHopData, attribution_data: Option, + send_timestamp: Option, + ) { + let prev_channel_id = hop_data.channel_id; + let prev_user_channel_id = hop_data.user_channel_id; + let prev_node_id = hop_data.counterparty_node_id; + let completed_blocker = RAAMonitorUpdateBlockingAction::from_prev_hop_data(&hop_data); + + // Obtain hold time, if available. + let hold_time = hold_time_since(send_timestamp).unwrap_or(0); + + // If attribution data was received from downstream, we shift it and get it ready for adding our hold + // time. Note that fulfilled HTLCs take a fast path to the incoming side. We don't need to wait for RAA + // to record the hold time like we do for failed HTLCs. + let attribution_data = process_fulfill_attribution_data( + attribution_data, + &hop_data.incoming_packet_shared_secret, + hold_time, + ); - if definitely_duplicate && startup_replay { - // On startup we may get redundant claims which are related to - // monitor updates still in flight. In that case, we shouldn't - // immediately free, but instead let that monitor update complete - // in the background. - #[cfg(test)] - { - let per_peer_state = self.per_peer_state.deadlocking_read(); - // The channel we'd unblock should already be closed, or... - let channel_closed = per_peer_state - .get(&next_channel_counterparty_node_id) - .map(|lck| lck.deadlocking_lock()) - .map(|peer| !peer.channel_by_id.contains_key(&next_channel_id)) - .unwrap_or(true); - let background_events = - self.pending_background_events.lock().unwrap(); - // there should be a `BackgroundEvent` pending... - let matching_bg_event = - background_events.iter().any(|ev| { - match ev { - // to apply a monitor update that blocked the claiming channel, - BackgroundEvent::MonitorUpdateRegeneratedOnStartup { - funding_txo, update, .. - } => { - if *funding_txo == claiming_chan_funding_outpoint { - assert!(update.updates.iter().any(|upd| - if let ChannelMonitorUpdateStep::PaymentPreimage { + #[cfg(test)] + let claiming_chan_funding_outpoint = hop_data.outpoint; + self.claim_funds_from_hop( + hop_data, + payment_preimage, + None, + Some(attribution_data), + |htlc_claim_value_msat, definitely_duplicate| { + let chan_to_release = Some(EventUnblockedChannel { + counterparty_node_id: next_channel_counterparty_node_id, + funding_txo: next_channel_outpoint, + channel_id: next_channel_id, + blocking_action: completed_blocker, + }); + + if definitely_duplicate && startup_replay { + // On startup we may get redundant claims which are related to + // monitor updates still in flight. In that case, we shouldn't + // immediately free, but instead let that monitor update complete + // in the background. + #[cfg(test)] + { + let per_peer_state = self.per_peer_state.deadlocking_read(); + // The channel we'd unblock should already be closed, or... + let channel_closed = per_peer_state + .get(&next_channel_counterparty_node_id) + .map(|lck| lck.deadlocking_lock()) + .map(|peer| !peer.channel_by_id.contains_key(&next_channel_id)) + .unwrap_or(true); + let background_events = self.pending_background_events.lock().unwrap(); + // there should be a `BackgroundEvent` pending... + let matching_bg_event = + background_events.iter().any(|ev| { + match ev { + // to apply a monitor update that blocked the claiming channel, + BackgroundEvent::MonitorUpdateRegeneratedOnStartup { + funding_txo, + update, + .. + } => { + if *funding_txo == claiming_chan_funding_outpoint { + assert!( + update.updates.iter().any(|upd| { + if let ChannelMonitorUpdateStep::PaymentPreimage { payment_preimage: update_preimage, .. } = upd { payment_preimage == *update_preimage } else { false } - ), "{:?}", update); - true - } else { false } - }, - // or the monitor update has completed and will unblock - // immediately once we get going. - BackgroundEvent::MonitorUpdatesComplete { - channel_id, .. - } => - *channel_id == prev_channel_id, + }), + "{:?}", + update + ); + true + } else { + false } - }); - assert!( - channel_closed || matching_bg_event, - "{:?}", - *background_events - ); - } - (None, None) - } else if definitely_duplicate { - if let Some(other_chan) = chan_to_release { - (Some(MonitorUpdateCompletionAction::FreeOtherChannelImmediately { - downstream_counterparty_node_id: other_chan.counterparty_node_id, - downstream_channel_id: other_chan.channel_id, - blocking_action: other_chan.blocking_action, - }), None) + }, + // or the monitor update has completed and will unblock + // immediately once we get going. + BackgroundEvent::MonitorUpdatesComplete { + channel_id, .. + } => *channel_id == prev_channel_id, + } + }); + assert!(channel_closed || matching_bg_event, "{:?}", *background_events); + } + (None, None) + } else if definitely_duplicate { + if let Some(other_chan) = chan_to_release { + ( + Some(MonitorUpdateCompletionAction::FreeOtherChannelImmediately { + downstream_counterparty_node_id: other_chan.counterparty_node_id, + downstream_channel_id: other_chan.channel_id, + blocking_action: other_chan.blocking_action, + }), + None, + ) + } else { + (None, None) + } + } else { + let total_fee_earned_msat = + if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { + if let Some(claimed_htlc_value) = htlc_claim_value_msat { + Some(claimed_htlc_value - forwarded_htlc_value) } else { - (None, None) + None } } else { - let total_fee_earned_msat = - if let Some(forwarded_htlc_value) = forwarded_htlc_value_msat { - if let Some(claimed_htlc_value) = htlc_claim_value_msat { - Some(claimed_htlc_value - forwarded_htlc_value) - } else { - None - } - } else { - None - }; - debug_assert!( - skimmed_fee_msat <= total_fee_earned_msat, - "skimmed_fee_msat must always be included in total_fee_earned_msat" - ); - ( - Some(MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { - event: events::Event::PaymentForwarded { - prev_channel_id: Some(prev_channel_id), - next_channel_id: Some(next_channel_id), - prev_user_channel_id, - next_user_channel_id, - prev_node_id, - next_node_id: Some(next_channel_counterparty_node_id), - total_fee_earned_msat, - skimmed_fee_msat, - claim_from_onchain_tx: from_onchain, - outbound_amount_forwarded_msat: forwarded_htlc_value_msat, - }, - downstream_counterparty_and_funding_outpoint: chan_to_release, - }), - None, - ) - } - }, - ); + None + }; + debug_assert!( + skimmed_fee_msat <= total_fee_earned_msat, + "skimmed_fee_msat must always be included in total_fee_earned_msat" + ); + ( + Some(MonitorUpdateCompletionAction::EmitEventAndFreeOtherChannel { + event: events::Event::PaymentForwarded { + prev_channel_id: Some(prev_channel_id), + next_channel_id: Some(next_channel_id), + prev_user_channel_id, + next_user_channel_id, + prev_node_id, + next_node_id: Some(next_channel_counterparty_node_id), + total_fee_earned_msat, + skimmed_fee_msat, + claim_from_onchain_tx: from_onchain, + outbound_amount_forwarded_msat: forwarded_htlc_value_msat, + }, + downstream_counterparty_and_funding_outpoint: chan_to_release, + }), + None, + ) + } }, - HTLCSource::TrampolineForward { .. } => todo!(), - } + ) } - /// Gets the node_id held by this ChannelManager pub fn get_our_node_id(&self) -> PublicKey { self.our_network_pubkey From 559f8db013acb2470337927573901318e622869a Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 14:14:09 -0400 Subject: [PATCH 11/15] Add trampoline routing failure handling Implement failure propagation for `HTLCSource::TrampolineForward` by iterating through previous hop data and failing each HTLC with `TemporaryTrampolineFailure`. Note that testing should be implemented when trampoline forward is completed. --- lightning/src/ln/channelmanager.rs | 43 +++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index cbb778c3e20..217c22f01f6 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8044,7 +8044,48 @@ where htlc_failure_reason, ); }, - HTLCSource::TrampolineForward { .. } => todo!(), + // TODO: This branch should be tested when Trampoline Forwarding is implemented. + HTLCSource::TrampolineForward { + previous_hop_data, + incoming_trampoline_shared_secret, + .. + } => { + // TODO: what do we want to do with this given we do not wish to propagate it directly? + let _decoded_onion_failure = + onion_error.decode_onion_failure(&self.secp_ctx, &self.logger, &source); + let incoming_trampoline_shared_secret = Some(*incoming_trampoline_shared_secret); + for current_hop_data in previous_hop_data { + let incoming_packet_shared_secret = + ¤t_hop_data.incoming_packet_shared_secret; + let channel_id = ¤t_hop_data.channel_id; + let short_channel_id = ¤t_hop_data.short_channel_id; + let htlc_id = ¤t_hop_data.htlc_id; + let blinded_failure = ¤t_hop_data.blinded_failure; + log_trace!( + WithContext::from(&self.logger, None, Some(*channel_id), Some(*payment_hash)), + "Failing {}HTLC with payment_hash {} backwards from us following Trampoline forwarding failure: {:?}", + if blinded_failure.is_some() { "blinded " } else { "" }, &payment_hash, onion_error + ); + let onion_error = HTLCFailReason::reason( + LocalHTLCFailureReason::TemporaryTrampolineFailure, + Vec::new(), + ); + let htlc_failure_reason = self.get_htlc_failure_from_blinded_failure_forward( + blinded_failure, + &onion_error, + incoming_packet_shared_secret, + &incoming_trampoline_shared_secret, + htlc_id, + ); + self.fail_htlc_backwards_from_forward( + &onion_error, + failure_type.clone(), + short_channel_id, + channel_id, + htlc_failure_reason, + ); + } + }, } } From 05448f5853dd42fda2c5d3a1001b859235964819 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 14:31:45 -0400 Subject: [PATCH 12/15] Add trampoline routing payment claiming Implement payment claiming for `HTLCSource::TrampolineForward` by iterating through previous hop data and claiming funds for each HTLC. Note that testing should be implemented when trampoline forwarding is completed. --- lightning/src/ln/channelmanager.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 217c22f01f6..6991b242abe 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8743,7 +8743,27 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ attribution_data, send_timestamp, ), - HTLCSource::TrampolineForward { .. } => todo!(), + // TODO: This branch should be tested when Trampoline Forwarding is implemented. + HTLCSource::TrampolineForward { previous_hop_data, .. } => { + for current_previous_hop_data in previous_hop_data { + self.claim_funds_from_previous_hop_internal( + payment_preimage, + forwarded_htlc_value_msat, + skimmed_fee_msat, + from_onchain, + startup_replay, + next_channel_counterparty_node_id, + next_channel_outpoint, + next_channel_id, + next_user_channel_id, + current_previous_hop_data, + // Clone the attribution data because it is going to be updated using the + // current_previous_hop_data respectively. + attribution_data.clone(), + send_timestamp, + ); + } + }, } } From a56f00c78f6ccadeaf85aabaad341fae50d3c364 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 15:33:44 -0400 Subject: [PATCH 13/15] Extract channelmonitor recovery to external helper Move recovery logic for `HTLCSource::PreviousHopData` into `channel_monitor_recovery_internal` to prepare for trampoline forward reuse. --- lightning/src/ln/channelmanager.rs | 109 +++++++++++++++++------------ 1 file changed, 64 insertions(+), 45 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 6991b242abe..73f8596df7d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -16666,51 +16666,16 @@ where ); match htlc_source { HTLCSource::PreviousHopData(prev_hop_data) => { - let pending_forward_matches_htlc = |info: &PendingAddHTLCInfo| { - info.prev_funding_outpoint == prev_hop_data.outpoint - && info.prev_htlc_id == prev_hop_data.htlc_id - }; - // The ChannelMonitor is now responsible for this HTLC's - // failure/success and will let us know what its outcome is. If we - // still have an entry for this HTLC in `forward_htlcs` or - // `pending_intercepted_htlcs`, we were apparently not persisted after - // the monitor was when forwarding the payment. - decode_update_add_htlcs.retain(|scid, update_add_htlcs| { - update_add_htlcs.retain(|update_add_htlc| { - let matches = *scid == prev_hop_data.short_channel_id && - update_add_htlc.htlc_id == prev_hop_data.htlc_id; - if matches { - log_info!(logger, "Removing pending to-decode HTLC with hash {} as it was forwarded to the closed channel {}", - &htlc.payment_hash, &monitor.channel_id()); - } - !matches - }); - !update_add_htlcs.is_empty() - }); - forward_htlcs.retain(|_, forwards| { - forwards.retain(|forward| { - if let HTLCForwardInfo::AddHTLC(htlc_info) = forward { - if pending_forward_matches_htlc(&htlc_info) { - log_info!(logger, "Removing pending to-forward HTLC with hash {} as it was forwarded to the closed channel {}", - &htlc.payment_hash, &monitor.channel_id()); - false - } else { true } - } else { true } - }); - !forwards.is_empty() - }); - pending_intercepted_htlcs.as_mut().unwrap().retain(|intercepted_id, htlc_info| { - if pending_forward_matches_htlc(&htlc_info) { - log_info!(logger, "Removing pending intercepted HTLC with hash {} as it was forwarded to the closed channel {}", - &htlc.payment_hash, &monitor.channel_id()); - pending_events_read.retain(|(event, _)| { - if let Event::HTLCIntercepted { intercept_id: ev_id, .. } = event { - intercepted_id != ev_id - } else { true } - }); - false - } else { true } - }); + channel_monitor_recovery_internal( + &mut forward_htlcs, + &mut pending_events_read, + &mut pending_intercepted_htlcs, + &mut decode_update_add_htlcs, + prev_hop_data, + &logger, + htlc.payment_hash, + monitor.channel_id(), + ); }, HTLCSource::TrampolineForward { .. } => todo!(), HTLCSource::OutboundRoute { @@ -17482,6 +17447,60 @@ where } } +fn channel_monitor_recovery_internal( + forward_htlcs: &mut HashMap>, + pending_events_read: &mut VecDeque<(Event, Option)>, + pending_intercepted_htlcs: &mut Option>, + decode_update_add_htlcs: &mut HashMap>, + prev_hop_data: HTLCPreviousHopData, logger: &impl Logger, payment_hash: PaymentHash, + channel_id: ChannelId, +) { + let pending_forward_matches_htlc = |info: &PendingAddHTLCInfo| { + info.prev_funding_outpoint == prev_hop_data.outpoint + && info.prev_htlc_id == prev_hop_data.htlc_id + }; + // The ChannelMonitor is now responsible for this HTLC's + // failure/success and will let us know what its outcome is. If we + // still have an entry for this HTLC in `forward_htlcs` or + // `pending_intercepted_htlcs`, we were apparently not persisted after + // the monitor was when forwarding the payment. + decode_update_add_htlcs.retain(|scid, update_add_htlcs| { + update_add_htlcs.retain(|update_add_htlc| { + let matches = *scid == prev_hop_data.short_channel_id && update_add_htlc.htlc_id == prev_hop_data.htlc_id; + if matches { + log_info!(logger, "Removing pending to-decode HTLC with hash {} as it was forwarded to the closed channel {}", + payment_hash, channel_id); + } + !matches + }); + !update_add_htlcs.is_empty() + }); + forward_htlcs.retain(|_, forwards| { + forwards.retain(|forward| { + if let HTLCForwardInfo::AddHTLC(htlc_info) = forward { + if pending_forward_matches_htlc(&htlc_info) { + log_info!(logger, "Removing pending to-forward HTLC with hash {} as it was forwarded to the closed channel {}", + payment_hash, channel_id); + false + } else { true } + } else { true } + }); + !forwards.is_empty() + }); + pending_intercepted_htlcs.as_mut().unwrap().retain(|intercepted_id, htlc_info| { + if pending_forward_matches_htlc(&htlc_info) { + log_info!(logger, "Removing pending intercepted HTLC with hash {} as it was forwarded to the closed channel {}", + payment_hash, channel_id); + pending_events_read.retain(|(event, _)| { + if let Event::HTLCIntercepted { intercept_id: ev_id, .. } = event { + intercepted_id != ev_id + } else { true } + }); + false + } else { true } + }); +} + #[cfg(test)] mod tests { use crate::events::{ClosureReason, Event, HTLCHandlingFailureType}; From 7f78fc48182de66682c59782ab8047c0d4f0aed3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 25 Aug 2025 15:38:34 -0400 Subject: [PATCH 14/15] Add channel monitor recovery for trampoline forwards Implement channel monitor recovery for trampoline forwards iterating over all hop data and updating pending forwards. --- lightning/src/ln/channelmanager.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 73f8596df7d..644da8d58de 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -16677,7 +16677,20 @@ where monitor.channel_id(), ); }, - HTLCSource::TrampolineForward { .. } => todo!(), + HTLCSource::TrampolineForward { previous_hop_data, .. } => { + for current_previous_hop_data in previous_hop_data { + channel_monitor_recovery_internal( + &mut forward_htlcs, + &mut pending_events_read, + &mut pending_intercepted_htlcs, + &mut decode_update_add_htlcs, + current_previous_hop_data, + &logger, + htlc.payment_hash, + monitor.channel_id(), + ); + } + }, HTLCSource::OutboundRoute { payment_id, session_priv, From 04d90537ed2cb1f7928fad4dc2ad66bdfedb7f70 Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Mon, 14 Jul 2025 15:10:02 -0700 Subject: [PATCH 15/15] Expand `HTLCDestination` variants for Trampoline forwards The previously existing `HTLCDestination` do not map nicely to the failure vent of a Trampoline forward, so we introduce a new variant to fill the gap. Co-authored-by: Arik Sosman --- lightning/src/events/mod.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 30c928297a8..81a6157cb02 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -557,6 +557,14 @@ pub enum HTLCHandlingFailureType { /// Short channel id we are requesting to forward an HTLC to. requested_forward_scid: u64, }, + /// We couldn't forward to the next Trampoline node. That may happen if we cannot find a route, + /// or if the route we found didn't work out. + FailedTrampolineForward { + /// The node ID of the next Trampoline hop we tried forwarding to. + requested_next_node_id: PublicKey, + /// The channel we tried forwarding over, if we have settled to one. + forward_scid: Option, + }, /// We couldn't decode the incoming onion to obtain the forwarding details. InvalidOnion, /// Failure scenario where an HTLC may have been forwarded to be intended for us, @@ -590,6 +598,10 @@ impl_writeable_tlv_based_enum_upgradable!(HTLCHandlingFailureType, (4, Receive) => { (0, payment_hash, required), }, + (5, FailedTrampolineForward) => { + (0, requested_next_node_id, required), + (2, forward_scid, option), + } ); /// The reason for HTLC failures in [`Event::HTLCHandlingFailed`].