Test trampoline fwd payload encoded as receive
What changed, and why it matters
This commit only adds a new automated test to the rust-lightning project. It does not change any production code. The test verifies that when a malformed trampoline-style payment onion is received, the node correctly rejects it and fails the payment backward. There is no security fix or vulnerability being introduced here.
No action required. This is a test-only change. Reviewers may optionally confirm the test accurately exercises the intended failure path and passes in CI.
Security signals we found
Adds regression/negative test for malformed trampoline onion payload handling
No changes to cryptographic, network, or state-machine logic
No privilege escalation, memory safety, or consensus-affecting changes
Evidence from the diff
The diff adds a single unit test, test_trampoline_forward_payload_encoded_as_receive, in lightning/src/ln/blinded_payment_tests.rs. The test constructs a blinded trampoline route where the final hop is encoded as a forward payload rather than a receive payload, replaces the onion routing packet in the outgoing HTLC with this malformed packet, and asserts that the payment fails with InvalidOnionPayload. No library or runtime logic is modified.
Changed components
lightning/src/ln/blinded_payment_tests.rsInspect captured patch +188 / −0
diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs
index 79697f0..d8dc094 100644
--- a/lightning/src/ln/blinded_payment_tests.rs
+++ b/lightning/src/ln/blinded_payment_tests.rs
@@ -1983,6 +1983,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,
+ )
+ };
+
+ 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();
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.