Skip to content

Commit da2c3d6

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 5b39bec commit da2c3d6

File tree

3 files changed

+121
-11
lines changed

3 files changed

+121
-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: 61 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,11 +61,12 @@ 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};
6768
use crate::sign::{NodeSigner, Recipient};
69+
use crate::sync::Arc;
6870
use crate::util::ser::Writeable;
6971

7072
/// This used to determine whether we built a compact path or not, but now its just a random
@@ -438,6 +440,63 @@ fn prefers_more_connected_nodes_in_blinded_paths() {
438440
}
439441
}
440442

443+
/// Tests the dummy hop behavior of Offers based on the message router used:
444+
/// - Compact paths (`DefaultMessageRouter`) should not include dummy hops.
445+
/// - Node ID paths (`NodeIdMessageRouter`) may include 0 to [`MAX_DUMMY_HOPS_COUNT`] dummy hops.
446+
#[test]
447+
fn check_dummy_hop_pattern_in_offer() {
448+
let chanmon_cfgs = create_chanmon_cfgs(2);
449+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
450+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
451+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
452+
453+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
454+
455+
let alice = &nodes[0];
456+
let deterministic_entropy = Arc::new(FixedEntropy);
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, deterministic_entropy.clone());
461+
462+
let compact_offer = alice.node
463+
.create_offer_builder_using_router(&default_router).unwrap()
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+
// Case 2: NodeIdMessageRouter → uses node ID-based blinded paths
476+
// Expected: 0 to MAX_DUMMY_HOPS_COUNT dummy hops, followed by recipient.
477+
let node_id_router = NodeIdMessageRouter::new(alice.network_graph, deterministic_entropy);
478+
479+
let padded_offer = alice.node
480+
.create_offer_builder_using_router(&node_id_router).unwrap()
481+
.build().unwrap();
482+
483+
assert!(!padded_offer.paths().is_empty());
484+
485+
for path in padded_offer.paths() {
486+
let hops = path.blinded_hops();
487+
assert!(
488+
hops.len() > 1,
489+
"Non-compact paths must include at least one dummy hop plus recipient"
490+
);
491+
492+
let dummy_count = hops.len() - 1;
493+
assert!(
494+
dummy_count <= MAX_DUMMY_HOPS_COUNT,
495+
"Dummy hops must not exceed MAX_DUMMY_HOPS_COUNT"
496+
);
497+
}
498+
}
499+
441500
/// Checks that blinded paths are compact for short-lived offers.
442501
#[test]
443502
fn creates_short_lived_offer() {

lightning/src/onion_message/functional_tests.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,34 @@ fn one_blinded_hop() {
443443
pass_along_path(&nodes);
444444
}
445445

446+
#[test]
447+
fn blinded_path_with_dummy() {
448+
let nodes = create_nodes(2);
449+
let test_msg = TestCustomMessage::Pong;
450+
451+
let secp_ctx = Secp256k1::new();
452+
let context = MessageContext::Custom(Vec::new());
453+
let entropy = &*nodes[1].entropy_source;
454+
let receive_key = nodes[1].messenger.node_signer.get_receive_auth_key();
455+
let blinded_path = BlindedMessagePath::new_with_dummy_hops(
456+
&[],
457+
nodes[1].node_id,
458+
5,
459+
receive_key,
460+
context,
461+
entropy,
462+
&secp_ctx,
463+
)
464+
.unwrap();
465+
// Ensure that dummy hops are added to the blinded path.
466+
assert_eq!(blinded_path.blinded_hops().len(), 6);
467+
let destination = Destination::BlindedPath(blinded_path);
468+
let instructions = MessageSendInstructions::WithoutReplyPath { destination };
469+
nodes[0].messenger.send_onion_message(test_msg, instructions).unwrap();
470+
nodes[1].custom_message_handler.expect_message(TestCustomMessage::Pong);
471+
pass_along_path(&nodes);
472+
}
473+
446474
#[test]
447475
fn two_unblinded_two_blinded() {
448476
let nodes = create_nodes(5);
@@ -658,9 +686,10 @@ fn test_blinded_path_padding_for_full_length_path() {
658686
let context = MessageContext::Custom(vec![0u8; 42]);
659687
let entropy = &*nodes[3].entropy_source;
660688
let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key();
661-
let blinded_path = BlindedMessagePath::new(
689+
let blinded_path = BlindedMessagePath::new_with_dummy_hops(
662690
&intermediate_nodes,
663691
nodes[3].node_id,
692+
5,
664693
receive_key,
665694
context,
666695
entropy,
@@ -694,9 +723,10 @@ fn test_blinded_path_no_padding_for_compact_path() {
694723
let context = MessageContext::Custom(vec![0u8; 42]);
695724
let entropy = &*nodes[3].entropy_source;
696725
let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key();
697-
let blinded_path = BlindedMessagePath::new(
726+
let blinded_path = BlindedMessagePath::new_with_dummy_hops(
698727
&intermediate_nodes,
699728
nodes[3].node_id,
729+
5,
700730
receive_key,
701731
context,
702732
entropy,

0 commit comments

Comments
 (0)