Skip to content

Conversation

@shaavan
Copy link
Member

@shaavan shaavan commented Oct 9, 2025

Builds on #4126

This PR adds Dummy Hop support for Blinded Payment Paths, paralleling the dummy-hop feature introduced for Blinded Message Paths in #3726.

By allowing arbitrary dummy hops before the real ReceiveTlvs, the length of a Blinded Payment Path can be increased to create a larger search space for an attacker trying to locate the true recipient. This reduces the risk of timing and position based deanonymization and improves user privacy.

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Oct 9, 2025

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@shaavan
Copy link
Member Author

shaavan commented Oct 9, 2025

The implementation is still in progress. The parsing logic that mitigates timing-based attacks is being refined. Once that’s settled, this PR will be ready for review.

@codecov
Copy link

codecov bot commented Oct 9, 2025

Codecov Report

❌ Patch coverage is 82.25806% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.52%. Comparing base (de384ff) to head (61a8d39).
⚠️ Report is 64 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/onion_payment.rs 13.79% 25 Missing ⚠️
lightning/src/blinded_path/payment.rs 89.15% 4 Missing and 5 partials ⚠️
lightning/src/ln/channelmanager.rs 89.02% 7 Missing and 2 partials ⚠️
lightning/src/ln/msgs.rs 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4152      +/-   ##
==========================================
- Coverage   89.33%   86.52%   -2.82%     
==========================================
  Files         180      158      -22     
  Lines      139042   102009   -37033     
  Branches   139042   102009   -37033     
==========================================
- Hits       124219    88260   -35959     
+ Misses      12196    11327     -869     
+ Partials     2627     2422     -205     
Flag Coverage Δ
fuzzing 35.11% <7.02%> (-0.86%) ⬇️
tests 85.82% <82.25%> (-2.89%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yay!

let tlvs = intermediate_nodes
.iter()
.map(|node| BlindedPaymentTlvsRef::Forward(&node.tlvs))
.chain((0..dummy_count).map(|_| BlindedPaymentTlvsRef::Dummy(&PaymentDummyTlv)))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a debug-assert that the forward hops and the dummy hops (ie all hops except the last) end up with the same length after padding?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I’ll add that in the next update. Thanks for pointing it out!

@shaavan
Copy link
Member Author

shaavan commented Nov 14, 2025

Updated: .01 → .02

Summary

This iteration introduces dummy hops before the actual receiver in a blinded path.
The current structure being evaluated is:

forward nodes -> dummy hops -> final receiver

The goal is to defend against potential timing side-channels at the receiver.
The mechanism works like this:

  • When the final receiver gets what appears to be the first dummy hop,
    it decodes and stores a new update_add_htlc.
  • This stored HTLC is decrypted only in the next background processing cycle,
    preventing immediate timing correlation attacks.

Conceptually this works, but it leads to two concrete issues, surfaced by failing tests.


Test Failures

1. rejects_keysend_to_non_static_invoice_path

Scenario:

Alice (Payer) --ss1--> Dummy01 --ss2--> Dummy02 --ss3--> Bob (Payee)

What happens:

  • Alice constructs the onion as usual.
    She cannot know which hops are dummy, so she generates a shared secret (ss) for each hop.
  • On receiving an invalid keysend, Bob tries to fail the HTLC backwards.
  • As expected, Bob encrypts the failure with ss3.

The problem:
Dummy hops are not part of an actual HTLC chain.
They are parsed and consumed in-place, so there is no backward path.

So Bob tries to send an encrypted failure using ss3… but there's nowhere for it to go.
For this to work, Bob would need to remember ss1 (the introduction hop's secret) so he could encrypt the error correctly, but with the current design, that state doesn’t exist anywhere without invasive changes to internal flow structures.

The receiver cannot fail an HTLC backward when dummy hops are present.


2. creates_offer_with_blinded_path_using_unannounced_introduction_node

This test explores an Offer scenario with an unannounced introduction node.

Background (pre-dummy-hops):

  • Alice creates an Offer.

  • Bob tries to pay it.

  • Alice and Bob share an unannounced channel.

  • Alice constructs a two-hop blinded path to herself, using Bob as the introduction node.

  • Bob recognizes himself as the introduction node and categorizes the remainder of blinded path as:

    CandidateRouteHop::OneHopBlinded { ... }
    

    (ref: router.rs L3346–3349

  • As per spec logic, this leads Bob to treat the fee as 0:

    (ref: router.rs L1750–1752)

All good.

After dummy hops are introduced before the final receiver:

  • Bob no longer sees himself as the sole real hop.
  • From his point of view, he looks like an intermediate hop in a multi-hop blinded path.
  • Because the hops are blinded, he cannot tell that the extra hops are dummy.
  • Consequently, he charges non-zero fees, leading to overpayment, and the test fails.

Also Alice cannot manually set the fee to zero in blinded_payinfo, because any node might be the payer.

This breaks the “one-hop-blinded” assumption used for Offers payable through unannounced nodes.


Conclusion

Both failures point to the same root issue:

Inserting dummy hops changes the semantic meaning of the blinded path for both the sender and the introduction node.

  • The payee loses the ability to fail an HTLC cleanly.
  • The introduction node loses its ability to detect a one-hop blinded path.
  • Neither side can special-case dummy hops without undermining the privacy properties dummy hops were meant to introduce.

I'm documenting this here as part of evaluating whether the dummy-hop approach (in this form) is viable, and to surface the structural implications before iterating further.

Feedback and alternative approaches welcome.

@shaavan
Copy link
Member Author

shaavan commented Nov 15, 2025

Updated: .02 → .03

Changes

Account for an existing fee-handling trade-off in LDK that leads to a small, intentional overpayment when the payer is the introduction node of a blinded path. This restores the creates_offer_with_blinded_path_using_unannounced_introduction_node test under the new dummy-hop model, without modifying production fee logic.

Details

LDK already has a known quirk in blinded-path handling:

  • When the payer is the introduction node, LDK does not subtract the forward fee for the payer -> next_hop channel.
  • This keeps the fee logic simpler and results in a small, intentional overpayment by the payer.
  • See BlindedPaymentPath::advance_path_by_one for the logic that omits this subtraction.

In the classic two-hop case where (payer as introduction node → payee), this overpayment used to be manually avoided in tests (see router.rs L1754–1756), because the path length was unambiguous and the introduction node could safely treat the effective fee as zero.

With the introduction of dummy hops, that assumption no longer holds:

  • Even when there are only two real nodes (payer + payee), the blinded path may contain multiple hops due to dummy entries.
  • From the introduction node’s perspective, it no longer appears to be a trivial one-hop-blinded path.
  • Consequently, it charges a non-zero fee, and the test now observes an overpayment.

Rather than change LDK’s fee semantics in this PR, I’ve chosen to make this overpayment explicit in the test framework and document the reasoning clearly. Once the underlying fee trade-off is revisited in LDK, this temporary adjustment can be removed with minimal changes.

@shaavan
Copy link
Member Author

shaavan commented Nov 15, 2025

Rebased: .03 → .04

@TheBlueMatt
Copy link
Collaborator

The receiver cannot fail an HTLC backward when dummy hops are present.

This shouldn't be an issue? We should be failing all blinded path receives with invalid_onion_blinding and we should thus not need the blinding point or shared secret.

Re: the fee issues, yea, its something we'll have to handle, but in practice what should happen is that we pick a route with a non-zero fee, then we go to pay it and when we start unwrapping the onion we notice that the fee is for us, allowing us to reclaim the funds.

Interestingly, this is a bit of a privacy leak - if the sender and the intro node are the same they can intuit that the remaining hops are blinded. Dunno if its worth doing anything about but we could add fees for the blinded hops.

@shaavan
Copy link
Member Author

shaavan commented Nov 18, 2025

Updated: .04 → .05

Changes:

  • Fixed the third test.

@TheBlueMatt

This shouldn't be an issue? We should be failing all blinded path receives with invalid_onion_blinding and we should thus not need the blinding point or shared secret.

Thanks a lot Matt, that comment really helped clear things up.

I had been assuming the existing test behaviour should stay the same even after introducing dummy hops, but your note made me rethink that.

In rejects_keysend_to_non_static_invoice_path, the original failure happened at the introduction node of the one-hop blinded path, so update_fail_htlc made sense there.

Once dummy hops are in the mix though, the failure no longer originates at the intro node. It surfaces from a deeper BlindedNode, so update_fail_malformed_htlc is indeed the right failure type here.

Updated the test accordingly. Thanks again!

@shaavan
Copy link
Member Author

shaavan commented Nov 18, 2025

Updated: .05 → .06

Changes:

Cleaned up the commits for clarity and flow.

Note

With this update, the first full version of Payment Dummy Hops for Blinded Payment Path receive is finally in place.
And it’s now ready for review.

The dummy-hop support for TrampolineBlindedReceive will follow in a separate PR.

@shaavan shaavan marked this pull request as ready for review November 18, 2025 18:57
@shaavan
Copy link
Member Author

shaavan commented Nov 19, 2025

Updated: .06 → .07

Addressed @TheBlueMatt’s comment — updates:

  • Added a debug-assert during blinded payment path construction to ensure all hops
    (except the final recipient) serialize to the same length.
  • Fixed the introduced test.
  • Removed the now-unnecessary PaymentDummyTlv struct as part of a small cleanup.

@shaavan shaavan requested a review from TheBlueMatt November 19, 2025 15:56
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @tankyleo! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@jkczyz jkczyz requested review from jkczyz and removed request for tankyleo November 24, 2025 16:26
@ldk-reviews-bot
Copy link

🔔 3rd Reminder

Hey @TheBlueMatt! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@shaavan shaavan force-pushed the pay-dummy branch 2 times, most recently from dcaa1e3 to f9c165c Compare November 27, 2025 14:40
@shaavan
Copy link
Member Author

shaavan commented Nov 27, 2025

Updated: .07 → .08

Thanks, @jkczyz - Changes:

  • Renamed ForwardInfo -> NextHopForwardInfo for clarity
  • DRYed up code, and test helpers
  • Corrected, and expanded documentation

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 3rd Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 3rd Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 4th Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 4th Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 5th Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 5th Reminder

Hey @TheBlueMatt @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@shaavan
Copy link
Member Author

shaavan commented Dec 30, 2025

Updated: .13 → .14

Thanks, @jkczyzchanges:

  1. Cleaned up code and documentation.

@ldk-reviews-bot
Copy link

🔔 6th Reminder

Hey @TheBlueMatt! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 7th Reminder

Hey @TheBlueMatt! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 8th Reminder

Hey @TheBlueMatt! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

payment_context: context,
}))
},
(None, None, None, None, None, None, None, Some(())) => Ok(BlindedPaymentTlvs::Dummy),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually feel like we should include a payment_relay in dummy payment hops. The biggest issue with padded payment hops is that the sender can (likely) identify the path actually taken based on feerate and CLTV-delta information. The use of payment_relay allows us to request that nodes require/take additional fee/CLTV delta on top of their published feerates/CLTV deltas as a way to add additional privacy. But, if we're gonna ask the payer for additional fees and CLTV, why are we "giving it" to other nodes? We should just require it on the dummy hops (where we get it, though I think this also addresses some off-by-ones in the fee enforcement logic which may be a privacy leak as well).

This requires enforcing the fees when decoding the onions as well, in addition to reporting it somehow in PaymentClaimable/PaymentClaimed (maybe just as a part of the total amount, though might be nice to break it out). I'm certainly fine with pushing that enforcement to another PR given this one is getting somewhat long in the tooth, but we should at least support deserializing the payment_relay field imo.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the pointer. I agree that introducing payment_relay for dummy hops makes sense, to prevent any feerates based path analysis.

I’ve added support for dummy-hop's payment_relay in .15, including parsing and enforcement during onion decoding.

For now, I’ve intentionally deferred exposing this via the public API or wiring it into DefaultRouter, as doing so would require more substantial changes to the existing testing framework. I’m planning to take that up in a follow-up, along with reflecting the dummy-hop balance in PaymentClaimable / PaymentClaimed.

})
},
onion_utils::Hop::Dummy { .. } => {
debug_assert!(false, "Dummy hop should have been peeled earlier");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this comment was in create_recv_pending_htlc_info (peel_payment_onion calls create_recv_pending_htlc_info if the onion_utils::Hop is Dummy)? Also the error message returned is wrong.

That said, maybe in peel_payment_onion we should handle the Dummy case by hand, peeling iteratively until we have something useful? That would avoid having to define a (serialized) PendingHTLCRouting::Dummy, which is pretty annoying. It would mean those using peel_payment_onion wouldn't be able to (easily) do iterative sleeps to simulate forwards in terms of timing which we do in ChannelManager, but that seems worth it to me?

/// Same as [`BlindedPaymentPath::new`], but allows specifying a number of dummy hops.
///
/// Note:
/// At most [`MAX_DUMMY_HOPS_COUNT`] dummy hops can be added to the blinded path.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accordingly we'll need to allow specifying the fees/CLTV delta for each dummy hop here, though probably that should wait until a PR where we start enforcing them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I’ve restructured the blinded path constructor in .15 so that introducing dummy_tlvs in the public API should be a minimal change.

I’ll expand the public API once we’re able to properly exercise and test dummy-hop fees in the test framework.

Dummy BlindedPaymentTlvs is an empty TLV inserted immediately before the
actual ReceiveTlvs in a blinded path. Receivers treat these dummy
hops as real hops, which prevents timing-based attacks.

Allowing arbitrary dummy hops before the final ReceiveTlvs obscures
the recipient's true position in the route and makes it harder for
an onlooker to infer the destination, strengthening recipient privacy.
Adds a new constructor for blinded paths that allows specifying
the number of dummy hops.
This enables users to insert arbitrary hops before the real destination,
enhancing privacy by making it harder to infer the sender–receiver
distance or identify the final destination.

Lays the groundwork for future use of dummy hops in blinded path construction.
Upcoming commits will need the ability to specify whether a blinded path
contains dummy hops. This change adds that support to the testing
framework ahead of time, so later tests can express dummy-hop scenarios
explicitly.
@shaavan
Copy link
Member Author

shaavan commented Jan 13, 2026

Updated: .14 → .15

Thanks, @TheBlueMatt. Updates:

  • Introduced payment_relay for dummy hops.
  • Updated peel_onion_payment to recursively peel dummy hops.

Details

This update introduces the following changes related to payment_relay handling for dummy hops:

  1. Dummy hops now carry their own payment_relay parameters.
  2. During parsing, dummy hops validate their payment constraints and subtract their hop-specific fees.
  3. During blinded path construction, dummy payment_relay values are taken into account when computing payinfo.

The following items are intentionally deferred to follow-up work:

  1. Exposing dummy TLVs in the public API.
  2. Updating DefaultRouter to use non-trivial dummy TLV values.
  3. Reporting additional fees earned by the receiver due to dummy hops in PaymentClaimable / PaymentClaimed.

Introducing finite payment_relay values in DefaultRouter would require significant changes to the testing framework. I wanted to avoid exposing new public API surface before this behavior is exercised and validated within LDK itself.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few minor things and then I'm definitely for sure happy with this! Thanks!

pub(super) fn compute_payinfo(
intermediate_nodes: &[PaymentForwardNode], payee_tlvs: &ReceiveTlvs,
payee_htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16,
intermediate_nodes: &[PaymentForwardNode], dummy_count: usize, dummy_tlvs: &DummyTlvs,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a followup where we move to picking dummies in routing, we'll definitely want to replace the count + single-DummyTlvs with a slice of DummyTlvs so that we can have different ones.

// before the real HTLC. Each call to `process_pending_htlc_forwards`
// strips exactly one dummy layer, so we call it N times.
for _ in 0..dummy_hop_override.unwrap_or(DEFAULT_PAYMENT_DUMMY_HOPS) {
node.node.process_pending_htlc_forwards();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also

Suggested change
node.node.process_pending_htlc_forwards();
assert!(node.node.needs_pending_htlc_processing());
node.node.process_pending_htlc_forwards();

}

#[rustfmt::skip]
fn check_dummy_forward(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please reuse check_blinded_forward (with BlindedHopFeatures::empty()) rather than copying it.

/// packet which must be processed again. This process repeats until a non-dummy
/// routing decision is reached, which is guaranteed to be
/// [`PendingHTLCRouting::Receive`].
Dummy {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can drop this now. We only had it for the onion_payment utils but we removed that.

@TheBlueMatt
Copy link
Collaborator

Also would be nice to test that an HTLC is rejected if it attempts to under-pay the blinded path requirements for dummy hops, but can come in a followup when we expose the fees on those hops.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants