Skip to content

Commit 27ad9b5

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 203b56e commit 27ad9b5

File tree

3 files changed

+149
-11
lines changed

3 files changed

+149
-11
lines changed

lightning/src/blinded_path/utils.rs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -276,16 +276,37 @@ 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();
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+
// Compute the extra encrypted overhead by taking the remainder.
303+
let extra = first_len % padding_round_off;
304+
305+
// All hops must follow the same padding structure:
306+
// their length minus `extra` should be a clean multiple of `padding_round_off`.
283307

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;
287308
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
309+
let len = hop.encrypted_payload.len();
310+
len > extra && (len - extra) % padding_round_off == 0
290311
})
291312
}

lightning/src/ln/offers_tests.rs

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ use bitcoin::network::Network;
4646
use bitcoin::secp256k1::{PublicKey, Secp256k1};
4747
use core::time::Duration;
4848
use crate::blinded_path::IntroductionNode;
49-
use crate::blinded_path::message::BlindedMessagePath;
49+
use crate::blinded_path::message::{BlindedMessagePath, MAX_DUMMY_HOPS_COUNT};
5050
use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, PaymentContext};
5151
use crate::blinded_path::message::OffersContext;
5252
use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose};
5353
use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self};
54+
use crate::offers::test_utils::FixedEntropy;
5455
use crate::types::features::Bolt12InvoiceFeatures;
5556
use crate::ln::functional_test_utils::*;
5657
use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement};
@@ -60,7 +61,7 @@ use crate::offers::invoice_error::InvoiceError;
6061
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields};
6162
use crate::offers::nonce::Nonce;
6263
use crate::offers::parse::Bolt12SemanticError;
63-
use crate::onion_message::messenger::{Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion};
64+
use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion};
6465
use crate::onion_message::offers::OffersMessage;
6566
use crate::routing::gossip::{NodeAlias, NodeId};
6667
use crate::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig};
@@ -435,6 +436,89 @@ fn prefers_more_connected_nodes_in_blinded_paths() {
435436
}
436437
}
437438

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