Skip to content

Commit fdf352b

Browse files
committed
Add test for dummy hop insertion
Introduces a test to verify correct handling of dummy hops in constructed blinded paths. Ensures that the added dummy hops are properly included and do not interfere with the real path.
1 parent 988defb commit fdf352b

File tree

3 files changed

+143
-13
lines changed

3 files changed

+143
-13
lines changed

lightning/src/blinded_path/utils.rs

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -276,16 +276,43 @@ impl<T: Writeable> Writeable for BlindedPathWithPadding<T> {
276276
}
277277

278278
#[cfg(test)]
279-
/// Checks if all the packets in the blinded path are properly padded.
279+
/// Verifies whether all hops in the blinded path follow the expected padding scheme.
280+
///
281+
/// In the padded encoding scheme, each hop's encrypted payload is expected to be of the form:
282+
/// `n * padding_round_off + extra`, where:
283+
/// - `padding_round_off` is the fixed block size to which unencrypted payloads are padded.
284+
/// - `n` is a positive integer (n ≥ 1).
285+
/// - `extra` is the fixed overhead added during encryption (assumed uniform across hops).
286+
///
287+
/// This function infers the `extra` from the first hop, and checks that all other hops conform
288+
/// to the same pattern.
289+
///
290+
/// # Returns
291+
/// - `true` if all hop payloads are padded correctly.
292+
/// - `false` if padding is incorrectly applied or intentionally absent (e.g., in compact paths).
280293
pub fn is_padded(hops: &[BlindedHop], padding_round_off: usize) -> bool {
281294
let first_hop = hops.first().expect("BlindedPath must have at least one hop");
282-
let first_payload_size = first_hop.encrypted_payload.len();
283-
284-
// The unencrypted payload data is padded before getting encrypted.
285-
// Assuming the first payload is padded properly, get the extra data length.
286-
let extra_length = first_payload_size % padding_round_off;
287-
hops.iter().all(|hop| {
288-
// Check that every packet is padded to the round off length subtracting the extra length.
289-
(hop.encrypted_payload.len() - extra_length) % padding_round_off == 0
290-
})
295+
let first_len = first_hop.encrypted_payload.len();
296+
297+
// Early rejection: if the first hop is too small, it can't be correctly padded.
298+
if first_len <= padding_round_off {
299+
return false;
300+
}
301+
302+
let extra = first_len % padding_round_off;
303+
304+
// Helper to check if a hop follows the padding pattern
305+
let is_hop_padded = |hop: &BlindedHop| {
306+
let len = hop.encrypted_payload.len();
307+
len > extra && (len - extra) % padding_round_off == 0
308+
};
309+
310+
// All hops must follow the same padding structure AND
311+
// all hops except the final one must have the same length as the first
312+
// to ensure proper masking.
313+
hops.iter().all(is_hop_padded)
314+
&& hops
315+
.iter()
316+
.take(hops.len().saturating_sub(1))
317+
.all(|hop| hop.encrypted_payload.len() == first_len)
291318
}

lightning/src/ln/offers_tests.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ use crate::offers::invoice_error::InvoiceError;
6060
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields};
6161
use crate::offers::nonce::Nonce;
6262
use crate::offers::parse::Bolt12SemanticError;
63-
use crate::onion_message::messenger::{Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion};
63+
use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, PADDED_PATH_LENGTH};
6464
use crate::onion_message::offers::OffersMessage;
6565
use crate::routing::gossip::{NodeAlias, NodeId};
6666
use crate::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig};
@@ -435,6 +435,76 @@ fn prefers_more_connected_nodes_in_blinded_paths() {
435435
}
436436
}
437437

438+
/// Tests the dummy hop behavior of Offers based on the message router used:
439+
/// - Compact paths (`DefaultMessageRouter`) should not include dummy hops.
440+
/// - Node ID paths (`NodeIdMessageRouter`) may include 0 to [`MAX_DUMMY_HOPS_COUNT`] dummy hops.
441+
///
442+
/// Also verifies that the resulting paths are functional: the counterparty can respond with a valid `invoice_request`.
443+
#[test]
444+
fn check_dummy_hop_pattern_in_offer() {
445+
let chanmon_cfgs = create_chanmon_cfgs(2);
446+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
447+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
448+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
449+
450+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
451+
452+
let alice = &nodes[0];
453+
let alice_id = alice.node.get_our_node_id();
454+
let bob = &nodes[1];
455+
let bob_id = bob.node.get_our_node_id();
456+
457+
// Case 1: DefaultMessageRouter → uses compact blinded paths (via SCIDs)
458+
// Expected: No dummy hops; each path contains only the recipient.
459+
let default_router = DefaultMessageRouter::new(alice.network_graph, alice.keys_manager);
460+
461+
let compact_offer = alice.node
462+
.create_offer_builder_using_router(&default_router).unwrap()
463+
.amount_msats(10_000_000)
464+
.build().unwrap();
465+
466+
assert!(!compact_offer.paths().is_empty());
467+
468+
for path in compact_offer.paths() {
469+
assert_eq!(
470+
path.blinded_hops().len(), 1,
471+
"Compact paths must include only the recipient"
472+
);
473+
}
474+
475+
let payment_id = PaymentId([1; 32]);
476+
bob.node.pay_for_offer(&compact_offer, None, None, None, payment_id, Retry::Attempts(0), RouteParametersConfig::default()).unwrap();
477+
478+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
479+
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
480+
481+
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
482+
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
483+
assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id));
484+
485+
// Case 2: NodeIdMessageRouter → uses node ID-based blinded paths
486+
// Expected: 0 to MAX_DUMMY_HOPS_COUNT dummy hops, followed by recipient.
487+
let node_id_router = NodeIdMessageRouter::new(alice.network_graph, alice.keys_manager);
488+
489+
let padded_offer = alice.node
490+
.create_offer_builder_using_router(&node_id_router).unwrap()
491+
.amount_msats(10_000_000)
492+
.build().unwrap();
493+
494+
assert!(!padded_offer.paths().is_empty());
495+
assert!(padded_offer.paths().iter().all(|path| path.blinded_hops().len() == PADDED_PATH_LENGTH));
496+
497+
let payment_id = PaymentId([2; 32]);
498+
bob.node.pay_for_offer(&padded_offer, None, None, None, payment_id, Retry::Attempts(0), RouteParametersConfig::default()).unwrap();
499+
500+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
501+
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
502+
503+
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
504+
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
505+
assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id));
506+
}
507+
438508
/// Checks that blinded paths are compact for short-lived offers.
439509
#[test]
440510
fn creates_short_lived_offer() {

lightning/src/onion_message/functional_tests.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ const CUSTOM_PONG_MESSAGE_TYPE: u64 = 4343;
145145
const CUSTOM_PING_MESSAGE_CONTENTS: [u8; 32] = [42; 32];
146146
const CUSTOM_PONG_MESSAGE_CONTENTS: [u8; 32] = [43; 32];
147147

148+
/// A dummy hop count for testing purposes.
149+
const TEST_DUMMY_HOP_COUNT: usize = 5;
150+
148151
impl OnionMessageContents for TestCustomMessage {
149152
fn tlv_type(&self) -> u64 {
150153
match self {
@@ -443,6 +446,34 @@ fn one_blinded_hop() {
443446
pass_along_path(&nodes);
444447
}
445448

449+
#[test]
450+
fn blinded_path_with_dummy_hops() {
451+
let nodes = create_nodes(2);
452+
let test_msg = TestCustomMessage::Pong;
453+
454+
let secp_ctx = Secp256k1::new();
455+
let context = MessageContext::Custom(Vec::new());
456+
let entropy = &*nodes[1].entropy_source;
457+
let receive_key = nodes[1].messenger.node_signer.get_receive_auth_key();
458+
let blinded_path = BlindedMessagePath::new_with_dummy_hops(
459+
&[],
460+
nodes[1].node_id,
461+
TEST_DUMMY_HOP_COUNT,
462+
receive_key,
463+
context,
464+
entropy,
465+
&secp_ctx,
466+
)
467+
.unwrap();
468+
// Ensure that dummy hops are added to the blinded path.
469+
assert_eq!(blinded_path.blinded_hops().len(), 6);
470+
let destination = Destination::BlindedPath(blinded_path);
471+
let instructions = MessageSendInstructions::WithoutReplyPath { destination };
472+
nodes[0].messenger.send_onion_message(test_msg, instructions).unwrap();
473+
nodes[1].custom_message_handler.expect_message(TestCustomMessage::Pong);
474+
pass_along_path(&nodes);
475+
}
476+
446477
#[test]
447478
fn two_unblinded_two_blinded() {
448479
let nodes = create_nodes(5);
@@ -658,9 +689,10 @@ fn test_blinded_path_padding_for_full_length_path() {
658689
let context = MessageContext::Custom(vec![0u8; 42]);
659690
let entropy = &*nodes[3].entropy_source;
660691
let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key();
661-
let blinded_path = BlindedMessagePath::new(
692+
let blinded_path = BlindedMessagePath::new_with_dummy_hops(
662693
&intermediate_nodes,
663694
nodes[3].node_id,
695+
TEST_DUMMY_HOP_COUNT,
664696
receive_key,
665697
context,
666698
entropy,
@@ -694,9 +726,10 @@ fn test_blinded_path_no_padding_for_compact_path() {
694726
let context = MessageContext::Custom(vec![0u8; 42]);
695727
let entropy = &*nodes[3].entropy_source;
696728
let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key();
697-
let blinded_path = BlindedMessagePath::new(
729+
let blinded_path = BlindedMessagePath::new_with_dummy_hops(
698730
&intermediate_nodes,
699731
nodes[3].node_id,
732+
TEST_DUMMY_HOP_COUNT,
700733
receive_key,
701734
context,
702735
entropy,

0 commit comments

Comments
 (0)