Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add LSPS5 DOS protections. by martinsaposnic · Pull Request #3993 · lightningdevkit/rust-lightning · GitHub
Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add LSPS5 DOS protections. by martinsaposnic · Pull Request #3993 · lightningdevkit/rust-lightning · GitHub
Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add LSPS5 DOS protections. by martinsaposnic · Pull Request #3993 · lightningdevkit/rust-lightning · GitHub
Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add LSPS5 DOS protections. by martinsaposnic · Pull Request #3993 · lightningdevkit/rust-lightning · GitHub
Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add LSPS5 DOS protections. by martinsaposnic · Pull Request #3993 · lightningdevkit/rust-lightning · GitHub
Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); Add LSPS5 DOS protections. by martinsaposnic · Pull Request #3993 · lightningdevkit/rust-lightning · GitHub
Skip to content

Add LSPS5 DOS protections. - #3993

Merged
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos
Sep 2, 2025
Merged

Add LSPS5 DOS protections.#3993
TheBlueMatt merged 1 commit into
lightningdevkit:mainfrom
martinsaposnic:lsps5-dos

Conversation

@martinsaposnic

@martinsaposnicmartinsaposnic commented Aug 7, 2025

Copy link
Copy Markdown
Contributor

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
Engaged meaning = active channel | LSPS2 active operation | LSPS1 active operation.

Logic: If not engaged then reject request;

A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future.

A few decisions that could be changed:

- the DOS protections are optional (default true). maybe this should not be configurable?
- I made the dos_protection_enforcer generic enough so it would be possible to add more behavior in the future. also some logic could be moved here like the ignored_peers logic from the manager, which could make sense to move to the dos enforcer

thoughts @tnull ?

@ldk-reviews-bot

ldk-reviews-bot commented Aug 7, 2025

Copy link
Copy Markdown

👋 Thanks for assigning @tnull 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.

@martinsaposnicmartinsaposnic mentioned this pull request Aug 7, 2025
18 tasks
@codecov

codecovBot commented Aug 7, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.23077% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.75%. Comparing base (5b6b691) to head (4370cff).
⚠️ Report is 10 commits behind head on main.

Files with missing linesPatch %Lines
lightning-liquidity/src/lsps5/msgs.rs75.00%2 Missing ⚠️
lightning-liquidity/src/manager.rs93.33%1 Missing ⚠️
Additional details and impacted files
@@ Coverage Diff @@## main #3993 +/- ##
==========================================
+ Coverage 88.73% 88.75% +0.02% 
==========================================
Files 176 176 Lines 129042 129094 +52 Branches 129042 129094 +52 ==========================================
+ Hits 114501 114583 +82 + Misses 11939 11911 -28 + Partials 2602 2600 -2 
FlagCoverage Δ
fuzzing22.29% <0.00%> (+0.37%)⬆️
tests88.59% <94.23%> (+0.02%)⬆️

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.

@tnull
tnull requested review from tnull and removed request for joostjagerAugust 7, 2025 17:53
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for looking into this!

use lightning::sign::NodeSigner;

/// A trait for implementing Denial-of-Service (DoS) protection mechanisms for LSP services.
pub trait DosProtectionEnforcer {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

IMO this is over-engineering. We can just have a single method on LSPS5ServiceHandler that checks the necessary bools. I also don't think this warrants to be in a separate module right now.

peer_state
.outbound_channels_by_intercept_scid
.values()
.any(|c| c.is_pending_channel_open())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, we'll also want to allow LSPS5 for any activity beyond this point, no? I think rather than adding helpers for each individual state, maybe we finally should expose the list of OutboundJITChannelStates at least pub(crate) and also implement Ord for OutboundJITChannelState? This would allow us to use comparison operators on the channel states.

Also, can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

@martinsaposnicmartinsaposnicAug 11, 2025

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

what I thought here (and I didn't clarify, sorry) is that it should only check for the pending_channel_open. after that state, the channel will be opened and the LSPS5 service has_active_channels check will kick in, so there is no point on checking the other LSPS2 states. does that make sense?

Comment threadlightning-liquidity/src/manager.rs Outdated
if !self.peer_is_engaged(sender_node_id) {
return Err(LightningError {
err: format!(
"Rejecting LSPS5 request from {:?} without existing engagement",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"existing engagement" is pretty unclear terminology, would be good to find more intuitive wording.

Comment threadlightning-liquidity/src/manager.rs Outdated
"Rejecting LSPS5 request from {:?} without existing engagement",
sender_node_id
),
action: ErrorAction::IgnoreAndLog(Level::Info),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please don't log on on Info here.

/// Maximum number of webhooks allowed per client.
pub max_webhooks_per_client: u32,
/// Require an existing channel or active LSPS1/LSPS2 flow before accepting requests.
pub enforce_dos_protections: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's at least start out with enabling this by default, i.e., don't make it configurable.

@ldk-reviews-bot

Copy link
Copy Markdown

👋 The first review has been submitted!

Do you think this PR is ready for a second reviewer? If so, click here to assign a second reviewer.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 38c8436 to ffe25e6CompareAugust 11, 2025 18:50
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

@tnull thanks for the review! I think this is a much better

I pushed a fixup commit addressing all the comments ffe25e6

I still have my doubts regarding this:

can we somehow enforce that for a pending payment, we'd always first process it and advance the LSPS2 state before evaluating whether we can notifiy the LSPS5 client?

I think that maybe that's not necessary and I posted a question related to that: #3993 (comment)

thanks!

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, much cleaner already! Some comments.

Comment threadlightning-liquidity/src/lsps1/service.rs
/// The different states a requested JIT channel can be in.
#[derive(Debug)]
enum OutboundJITChannelState {
#[derive(Clone, Debug, PartialEq, Eq)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why does this need to be Clone suddenly?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

PaymentForwarded { channel_id: ChannelId },
}

impl OutboundJITChannelState {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need this explicit implementation? Wouldn't derive(PartialOrd) do the ~the same thing?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this would need putting also the derive(Ord) and derive(PartialOrd) on PaymentQueue and InterceptId

happy to do it but wanted to minimize changes

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

seems like I can't define Ord but derive PartialOrd

from the linter:

error: you are implementing `Ord` explicitly but have derived `PartialOrd`
--> lightning-liquidity/src/lsps2/service.rs:164:1
|
164 | / impl Ord for OutboundJITChannelState {
165 | | fn cmp(&self, other: &Self) -> core::cmp::Ordering {
166 | | self.stage().cmp(&other.stage())
167 | | }
168 | | }
| |_^

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry. Then I have to eat my words. I'd actually prefer the way you had vs. introducing the redundant ..Stage object. Sorry, mind reverting to explicitly implementing it?

let outer_state_lock = self.per_peer_state.read().unwrap();
if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) {
let peer_state = inner_state_lock.lock().unwrap();
peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't buy we need to clone here.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

error[E0507]: cannot move out of `c.state` which is behind a shared reference
--> lightning-liquidity/src/lsps2/service.rs:599:68
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state).max()
| ^^^^^^^ move occurs because `c.state` has type `OutboundJITChannelState`, which does not implement the `Copy` trait
|
help: consider cloning the value if the performance cost is acceptable
|
599 | peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max()
| ++++++++

@tnulltnullAug 13, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let me rephrase: we definitely can't clone the entire state, with all the HTLC data, etc each time we want to check whether a client reached a certain state. I still think handling all the state by reference should be doable, but it's probably easiest to mirror the fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool above.

(While eventually we want to give users insight into the held state, it doesn't need to happen in this PR, so for now it's probably easiest to revert to having OutboundJITChannelState private, and just check the bool returned by the helper mentioned above)

) -> bool {
self.client_has_open_channel(client_id)
|| lsps1_has_activity
|| lsps2_max_state.map_or(false, |s| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, why not just use >= PendingChannelOpen now that we can?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

this could not be done directly because I would need to create some dummy properties for the PendingChannelOpen struct for it to be able to compare:

|s| s >= OutboundJITChannelState::PendingChannelOpen { payment_queue: PaymentQueue::new(), opening_fee_msat: 0 })

I prefer not to do this so went with something different:

  • dropped the OutboundJITChannelState::ord_index()
  • instead, I created a new enum OutboundJITStage that matches one to one with OutboundJITChannelState but has no properties. this new OutboundJITStage has Derive(ord, partialOrd) so it just grabs the declaration order for its ordering

with this, now we can do |s| s.stage() >= OutboundJITStage::PendingChannelOpen

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, see #3993 (comment)

I'd prefer not to introduce a redundant object here. Sorry for the noise.

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 5 times, most recently from 8eaea29 to 9a59755CompareAugust 12, 2025 16:57
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

I squashed the last fixup commit and created a new fixup commit addressing the latest comments

also responded to some of the comments that may need a follow up

thanks @tnull !

@tnull
tnull removed their request for review August 13, 2025 07:01
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the latest comments a74c585

  • made the OutboundJITChannelState private again
  • on lsps2/service, have a bool function that returns if the node_id has an opening or open channel
  • use that new function on the manager and on lsps5/service

let me know what you think @tnull , thanks!!

@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 3 times, most recently from 142842b to 109de16CompareAugust 13, 2025 18:24
peer_state.outbound_channels_by_intercept_scid.values().any(|chan| {
matches!(
chan.state,
OutboundJITChannelState::PendingChannelOpen { .. }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait, how do we imagine this to work for the initial LSPS2 receive? If we only allow clients to even register for notifications once we already have a (pending) channel open, how would a first-time user's phone get notified to wake up and accept the channel when they receive the very first payment?

I think we'd need to extend this to also accept LSPS5 requests for PendingInitialPayment?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

yeah, that makes sense. I will update this

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

@tnull let me know what you think 8f2805a. thanks!!

Comment threadlightning-liquidity/src/manager.rs Outdated
@martinsaposnic
martinsaposnicforce-pushed the lsps5-dos branch 2 times, most recently from 5148642 to e9e2addCompareAugust 22, 2025 16:08

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

actually I'm thinking that probably we need a service event for communicating that the request is being rejected? right now it silently fails. the client would not have a clue that it failed because of DoS protection reasons. thoughts?

Yea, ISTM we should respond with SetWebhookError.

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 1st Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

ISTM we should respond with SetWebhookError.

last fixup commit adds logic to respond with an error instead of silently failing 6041e71

this should be ready for review again @TheBlueMatt , thanks!!

@ldk-reviews-bot

Copy link
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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
Copy Markdown

🔔 1st Reminder

Hey @tnull@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
Copy Markdown

🔔 3rd Reminder

Hey @tnull@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
Copy Markdown

🔔 2nd Reminder

Hey @tnull@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.

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Didn't take a super close look at the tests but this LGTM, feel free to squash.

@TheBlueMattTheBlueMatt added this to the 0.2 milestone Aug 30, 2025
@ldk-reviews-bot

Copy link
Copy Markdown

🔔 4th Reminder

Hey @tnull! 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.

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

Didn't take a super close look at the tests but this LGTM, feel free to squash.

just squashed all fixup commits

there were no conflicts with main, but I rebased because it's been a while since the last one. the force-push compare is messed up because of that, but I haven't introduced any additional changes. sorry 🙃

tnull
tnull previously approved these changes Sep 1, 2025

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM, mod two nits.

}
}

macro_rules! assert_lsps5_reject {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: Using methods instead of macros would be preferable, as it improves debugging if something inside of the assert fails. But feel free to leave as is for now.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

agreed

);
}

fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rather than just adding this at the end, can we move this up to the other helper methods at the start of the file?

@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

just pushed a fixup commit addressing the last 2 comments. thanks @tnull !

@tnulltnull left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to squash!

When handling an incoming LSPS5 request, the manager will check
if the counterparty is 'engaged' in some way before responding.
`Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation.
Logic: `If not engaged then reject request;`
A single test is added only checking for the active channel condition,
because it's not super easy to get LSPS1-2 on the correct state to check this (yet).
Other tangential work is happening that will make this easier and more tests will come in the near future
@martinsaposnic

Copy link
Copy Markdown
ContributorAuthor

all fixups are squashed now!

@TheBlueMattTheBlueMatt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks

@TheBlueMatt
TheBlueMatt merged commit bf87832 into lightningdevkit:mainSep 2, 2025
23 of 24 checks passed
Sign up for freeto 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

@martinsaposnic@ldk-reviews-bot@tnull@TheBlueMatt