From 6b6ac93b988d5d19f53a820293b7994483995443 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 14 Aug 2025 15:00:03 -0400 Subject: [PATCH 1/2] 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 d45860b0e26..1a8a5a138e7 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -1678,6 +1678,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 { @@ -1718,6 +1725,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, } } @@ -1852,6 +1862,9 @@ impl_writeable_tlv_based_enum!(LocalHTLCFailureReason, (79, HTLCMinimum) => {}, (81, HTLCMaximum) => {}, (83, PeerOffline) => {}, + (85, TemporaryTrampolineFailure) => {}, + (87, TrampolineFeeOrExpiryInsufficient) => {}, + (89, UnknownNextTrampoline) => {}, ); impl From<&HTLCFailReason> for HTLCHandlingFailureReason { @@ -2018,6 +2031,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 91f529688000b951d5f2cc2c6621b83efdb5190b Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 14 Aug 2025 15:01:31 -0400 Subject: [PATCH 2/2] 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 | 477 +++++++++++++++++++++- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/onion_payment.rs | 79 +++- 3 files changed, 540 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index d72c816cba9..cd0ea18f66d 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -2086,7 +2086,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: carol_blinded_hops, @@ -2206,9 +2206,250 @@ fn test_trampoline_single_hop_receive() { do_test_trampoline_single_hop_receive(false); } +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(); + + 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(); + + 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: 0, // no trampoline fee because we are receiving. + cltv_expiry_delta: 72, // blinded hop cltv to be used building the outer onion. + }, + ], + hops: carol_blinded_hops, + blinding_point: carol_blinding_point, + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + + let payment_id = PaymentId(payment_hash.0); + + 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 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(); + let replacement_payload_amount = if underpay { amt_msat * 2 } else { amt_msat }; + + trampoline_payloads.push(msgs::OutboundTrampolinePayload::Receive { + payment_data: Some(msgs::FinalOnionHopData { + payment_secret, + total_msat: replacement_payload_amount, + }), + 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, + }); + + 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_packet = onion_utils::construct_trampoline_onion_packet( + trampoline_payloads, + trampoline_onion_keys, + prng_seed.secret_bytes(), + &payment_hash, + None, + ).unwrap(); + + // Get the original inner session private key that the ChannelManager generated so we can + // re-use it for the outer session private key. This way HMAC validation in attributable + // errors does not makes the test fail. + let mut orig_inner_priv_bytes = [0u8; 32]; + nodes[0].node.test_modify_pending_payment(&payment_id, |pmt| { + if let crate::ln::outbound_payment::PendingOutboundPayment::Retryable { session_privs, .. } = pmt { + orig_inner_priv_bytes = *session_privs.iter().next().unwrap(); + } + }); + let inner_session_priv = SecretKey::from_slice(&orig_inner_priv_bytes).unwrap(); + + // Derive the outer session private key from the inner one. + let outer_session_priv_hash = Sha256::hash(&inner_session_priv.secret_bytes()); + let outer_session_priv = SecretKey::from_slice(&outer_session_priv_hash.to_byte_array()).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, + prng_seed.secret_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() { - // Simulate a payment of A (0) -> B (1) -> C(Trampoline) (2) +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(); @@ -2257,6 +2498,15 @@ fn test_trampoline_unblinded_receive() { &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![ @@ -2277,7 +2527,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, } @@ -2289,11 +2539,12 @@ fn test_trampoline_unblinded_receive() { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + 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, }) @@ -2301,6 +2552,8 @@ fn test_trampoline_unblinded_receive() { route_params: None, }; + let payment_id = PaymentId(payment_hash.0); + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); let replacement_onion = { @@ -2311,18 +2564,17 @@ fn test_trampoline_unblinded_receive() { 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(); + let (mut trampoline_payloads, 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(); // pop the last dummy hop trampoline_payloads.pop(); - trampoline_payloads.push(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, + 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_secret_key); @@ -2334,10 +2586,20 @@ fn test_trampoline_unblinded_receive() { 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"); + // Get the original inner session private key that the ChannelManager generated so we can + // re-use it for the outer session private key. This way HMAC validation in attributable + // errors does not makes the test fail. + let mut orig_inner_priv_bytes = [0u8; 32]; + nodes[0].node.test_modify_pending_payment(&payment_id, |pmt| { + if let crate::ln::outbound_payment::PendingOutboundPayment::Retryable { session_privs, .. } = pmt { + orig_inner_priv_bytes = *session_privs.iter().next().unwrap(); + } + }); + let inner_session_priv = SecretKey::from_slice(&orig_inner_priv_bytes).unwrap(); + // Derive the outer session private key from the inner one. + let outer_session_priv_hash = Sha256::hash(&inner_session_priv.secret_bytes()); + let outer_session_priv = SecretKey::from_slice(&outer_session_priv_hash.to_byte_array()).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( @@ -2368,10 +2630,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, false, 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, false, 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/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1fd99f89451..57aea00c0ea 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5121,7 +5121,7 @@ where ) } - #[cfg(all(test, async_payments))] + #[cfg(test)] pub(crate) fn test_modify_pending_payment(&self, payment_id: &PaymentId, mut callback: Fn) where Fn: FnMut(&mut PendingOutboundPayment), 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 };