Skip to content

LSP-Plugin: Add MPP support - #8948

Open
nepet wants to merge 1 commit into
ElementsProject:masterfrom
nepet:plugins/lsps2/mpp-fsm
Open

LSP-Plugin: Add MPP support#8948
nepet wants to merge 1 commit into
ElementsProject:masterfrom
nepet:plugins/lsps2/mpp-fsm

Conversation

@nepet

@nepet nepet commented Mar 18, 2026

Copy link
Copy Markdown
Member

Important

26.04 FREEZE March 11th: Non-bugfix PRs not ready by this date will wait for 26.06.

RC1 is scheduled on March 23rd

The final release is scheduled for April 15th.

Checklist

Before submitting the PR, ensure the following tasks are completed. If an item is not applicable to your PR, please mark it as checked:

  • The changelog has been updated in the relevant commit(s) according to the guidelines.
  • Tests have been added or modified to reflect the changes.
  • Documentation has been reviewed and updated as needed.
  • Related issues have been listed and linked, including any that this PR closes.
  • Important All PRs must consider how to reverse any persistent changes for tools/lightning-downgrade

Introduces a state-machine-based approach to managing LSPS2 JIT channel sessions, replacing the previous ad-hoc state tracking with a structured FSM that tracks payment collection from initial channel open through HTLC forwarding to completion.

@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from dc5b671 to 44f666d Compare March 18, 2026 23:33
@nepet
nepet requested a review from cdecker March 18, 2026 23:36
@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from 44f666d to df7c132 Compare March 19, 2026 10:33
@madelinevibes madelinevibes added this to the v26.04 milestone Mar 20, 2026
@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from 73b18ed to da925c6 Compare March 22, 2026 10:06
@madelinevibes madelinevibes modified the milestones: v26.04, 26.06 Mar 22, 2026
@cdecker

cdecker commented Mar 23, 2026

Copy link
Copy Markdown
Member

Cache cleared, and restarting the CI while I review the code 👍

@cdecker

cdecker commented Mar 23, 2026

Copy link
Copy Markdown
Member

Nice PR, maybe a bit on the long side, and a bit of duplication, but the architecture is nice.

ACK

Comment thread plugins/lsps-plugin/src/core/lsps2/session.rs Outdated
)]
InsufficientDeductibleCapacity {
opening_fee_msat: u64,
deductible_capacity_msat: u128,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why the different types here?


#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaymentPart {
pub htlc_id: u64,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just a quick doubt I had: this refers to which ID? As far as I remember the numbering of HTLCs was using a composite (channel_id, htlc_id) key because the protocol insists on numbering HTLCs, making either an alias necessary or the composite key to make them unique.

This is the DB HTLC ID that counts up monotonically, not the protocol ID which is per-channel, correct?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Very good question indeed, I'll need to check what exactly the htlc_accepted hook provides.

}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionEvent {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That's quite the extensive list of potential errors and outcomes, nice listing 👍

}
}

pub fn apply(&mut self, input: SessionInput) -> Result<ApplyResult> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You'll probably want to break this up into a dispatch method (containing the match (field_a, field_b, input)) and several handler methods that take the involved parts and pieces of information as explicit parameters. That'll help get a quick overview, and allow diving deep on specific operations if interested.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

do you mean something like the following? This would indeed make it more readable.

pub fn apply(&mut self, input: SessionInput) -> Result<ApplyResult> {
    match(&mut self.stat) {
        SessionState::Collecting => apply_on_collecting(input),
        SessionState::AwaitingChannelReady => apply_on_awaiting_channel_ready(input),
        ....
    }
}

// Maybe needs to get the state passed here and reinjected into the Session later
fn apply_on_collecting(&mut self, input: SessionInput) -> Result<ApplyResult> {
    // Check what needs to be "taken" here to please the ownership model
    match(input) {
        ....
    }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yep, pretty much, can be a cleanup though. Also a quick diagram on allowable state changes would make this much easier to follow as well. All followup things though, not a blocker.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That is not to say I don't like the (state, event) matching, as that automatically gives you an indication whether all states and transitions are covered, via the exhaustive enumeration rule in Rust.

scid: ShortChannelId,
datastore: D,
) -> ActorInboxHandle {
let (tx, inbox) = mpsc::channel(128); // Should we use max_htlcs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The backlog is mostly intended for bursty behavior, and should be set to the maximum number of events in flight. If there is no more room, we will drop block the sending side, no messages should be lost. If the sender does not need to make progress, and the expectation is that we can process messages in the order they arrive (no interleaving) it should be safe to set the backlog to 1. Don't think about elements queued up, rather consider if you need to make progress on the sendign side at all, while elements are being processed.

}
}

fn execute_action(&mut self, action: SessionAction) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Love the match () {} matrix, less so the deep nesting, as it pulls the matrix apart and makes reasoning about its relations and transitions harder :-)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'll clean it up and move stuff into handler functions

Comment thread plugins/lsps-plugin/src/client.rs Outdated
return Ok(serde_json::json!({
"result": "continue",
"mindepth": 0,
"reserve": 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Hm, this will break megalithic LSP? They do not have a way to set no reserve.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Whoo! GOOD catch. totally forgot about megalith here. I'll make it an option with a sane (set nothing) default

};

// Main loop: process inbox events
loop {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this not duplicating the entire FSM logic, just because we enter the system through recovery, rather than kicking off a new session? We could just call into the dispatch of events here, and everything else would be the same, or am I missing something?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Let me see if I can clean it up a bit

) -> Result<(String, String)> {
(**self)
.fund_channel(peer_id, channel_capacity_msat, opening_fee_params)
.fund_channel(peer_id, channel_capacity_msat, opening_fee_params, scid)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are there "let's define ALL the operations on a variant of the original behavior" useful? I am failing to see how defining the operations on Arc<T> and then just having them forward to T could be useful 🤔

@cdecker

cdecker commented Mar 23, 2026

Copy link
Copy Markdown
Member

The CI failures appear to NOT be flaky tests, as I am seeing a lot of the new tests failing on us.

@sangbida
sangbida force-pushed the plugins/lsps2/mpp-fsm branch from da925c6 to d0fc010 Compare April 1, 2026 03:07
@madelinevibes

Copy link
Copy Markdown
Collaborator

@nepet can you rebase, resolve conflicts and clear CI by the end of this week so we can add this to 26.06?
Release candidate planned for Monday 11 May.

@daywalker90

Copy link
Copy Markdown
Collaborator

rebased on master

pub opening_fee_params: OpeningFeeParams,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_payment_size: Option<Msat>,
pub channel_capacity_msat: Msat,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These are guaranteed to be new entries, right? If you try to load an existing one with non-optional fields it'll likely fail.

}
}

pub fn apply(&mut self, input: SessionInput) -> Result<ApplyResult> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yep, pretty much, can be a cleanup though. Also a quick diagram on allowable state changes would make this much easier to follow as well. All followup things though, not a blocker.

return Ok(ApplyResult {
actions: vec![
SessionAction::FailHtlcs {
failure_code: UNKNOWN_NEXT_PEER,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You probably don't want to use UNKNOWN_NEXT_PEER as that is a permanent error, and the client is supposed to remember that the channel never existed, and future attempts with blocklist them. It's a common error lnd always gets wrong too.

// We don't check for max parts here as we are in the middle of
// the channel funding. We'll check once we transitioned.

Ok(ApplyResult {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry my eyes glazed over at this point, I trust the logic is sensible, as reconstructing it from code is painful.

}
}

pub fn apply(&mut self, input: SessionInput) -> Result<ApplyResult> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

That is not to say I don't like the (state, event) matching, as that automatically gives you an indication whether all states and transitions are covered, via the exhaustive enumeration rule in Rust.

cdecker
cdecker previously approved these changes May 7, 2026
@madelinevibes madelinevibes modified the milestones: v26.06, v26.09 May 11, 2026
nepet added a commit to nepet/lightning that referenced this pull request Jul 3, 2026
The UNKNOWN_NEXT_PEER constant carried the value 0x4010 which is not a
valid BOLT4 failure code; unknown_next_peer is PERM|10 = 0x400a. Every
path that was supposed to fail with unknown_next_peer actually sent the
deprecated incorrect_payment_amount code.

Also stop using the permanent unknown_next_peer for retryable failures
(too many parts, fee allocation failure, channel funding failure) as
senders are expected to blocklist the route on permanent errors. These
now fail with temporary_channel_failure; LSPS2 only mandates
unknown_next_peer when the payment cannot cover the opening fee or
valid_until has passed, and requires temporary_channel_failure for a
client disconnect during channel establishment.

Addresses review feedback by @cdecker on ElementsProject#8948.
nepet added a commit to nepet/lightning that referenced this pull request Jul 3, 2026
HTLC ids are a per-channel counter, so MPP parts of one payment
arriving over different incoming channels can share the same id -- the
common case for multi-part payments. The session actor tracked pending
HTLC replies in a map keyed by the bare id, so a colliding part
silently replaced the earlier part's reply channel: the earlier hook
call resolved to a generic continue (failing the part upstream with
WIRE_UNKNOWN_NEXT_PEER), the manager misread the dropped reply as a
dead actor and removed the live session, and forward amounts could be
cross-wired between parts.

Introduce HtlcId as the composite of the incoming channel scid and the
per-channel id, carry it in PaymentPart/ForwardPart and key the
pending-reply map with it.

Addresses review feedback by @cdecker on ElementsProject#8948 (HTLC id ambiguity).
nepet added a commit to nepet/lightning that referenced this pull request Jul 3, 2026
Both sides unconditionally requested a zero channel reserve on JIT
channels: the client's openchannel hook always replied reserve=0 and
the LSP's fundchannel_start always sent reserve=0. Some LSP
implementations (e.g. Megalithic) cannot handle an explicit zero
reserve. Leave the reserve at lightningd's default and add opt-in
flags: experimental-lsps-client-zero-reserve on the client and
experimental-lsps2-zero-reserve on the service.

Also add uniform #[serde(default)] on DatastoreEntry's optional fields
and a regression test pinning that entries persisted without the newer
fields keep deserializing.

Addresses review feedback by @cdecker on ElementsProject#8948 (Megalithic reserve).
nepet added a commit to nepet/lightning that referenced this pull request Jul 3, 2026
The UNKNOWN_NEXT_PEER constant carried the value 0x4010 which is not a
valid BOLT4 failure code; unknown_next_peer is PERM|10 = 0x400a. Every
path that was supposed to fail with unknown_next_peer actually sent the
deprecated incorrect_payment_amount code.

Also stop using the permanent unknown_next_peer for retryable failures
(too many parts, fee allocation failure, channel funding failure) as
senders are expected to blocklist the route on permanent errors. These
now fail with temporary_channel_failure; LSPS2 only mandates
unknown_next_peer when the payment cannot cover the opening fee or
valid_until has passed, and requires temporary_channel_failure for a
client disconnect during channel establishment.

Addresses review feedback by @cdecker on ElementsProject#8948.
nepet added a commit to nepet/lightning that referenced this pull request Jul 3, 2026
HTLC ids are a per-channel counter, so MPP parts of one payment
arriving over different incoming channels can share the same id -- the
common case for multi-part payments. The session actor tracked pending
HTLC replies in a map keyed by the bare id, so a colliding part
silently replaced the earlier part's reply channel: the earlier hook
call resolved to a generic continue (failing the part upstream with
WIRE_UNKNOWN_NEXT_PEER), the manager misread the dropped reply as a
dead actor and removed the live session, and forward amounts could be
cross-wired between parts.

Introduce HtlcId as the composite of the incoming channel scid and the
per-channel id, carry it in PaymentPart/ForwardPart and key the
pending-reply map with it.

Addresses review feedback by @cdecker on ElementsProject#8948 (HTLC id ambiguity).
nepet added a commit to nepet/lightning that referenced this pull request Jul 3, 2026
Both sides unconditionally requested a zero channel reserve on JIT
channels: the client's openchannel hook always replied reserve=0 and
the LSP's fundchannel_start always sent reserve=0. Some LSP
implementations (e.g. Megalithic) cannot handle an explicit zero
reserve. Leave the reserve at lightningd's default and add opt-in
flags: experimental-lsps-client-zero-reserve on the client and
experimental-lsps2-zero-reserve on the service.

Also add uniform #[serde(default)] on DatastoreEntry's optional fields
and a regression test pinning that entries persisted without the newer
fields keep deserializing.

Addresses review feedback by @cdecker on ElementsProject#8948 (Megalithic reserve).
@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from 36c53dd to 4c58bff Compare July 3, 2026 16:04
nepet added a commit to nepet/lightning that referenced this pull request Aug 19, 2026
HTLC ids are a per-channel counter, so MPP parts of one payment
arriving over different incoming channels can share the same id -- the
common case for multi-part payments. The session actor tracked pending
HTLC replies in a map keyed by the bare id, so a colliding part
silently replaced the earlier part's reply channel: the earlier hook
call resolved to a generic continue (failing the part upstream with
WIRE_UNKNOWN_NEXT_PEER), the manager misread the dropped reply as a
dead actor and removed the live session, and forward amounts could be
cross-wired between parts.

Introduce HtlcId as the composite of the incoming channel scid and the
per-channel id, carry it in PaymentPart/ForwardPart and key the
pending-reply map with it.

Addresses review feedback by @cdecker on ElementsProject#8948 (HTLC id ambiguity).
nepet added a commit to nepet/lightning that referenced this pull request Aug 19, 2026
Both sides unconditionally requested a zero channel reserve on JIT
channels: the client's openchannel hook always replied reserve=0 and
the LSP's fundchannel_start always sent reserve=0. Some LSP
implementations (e.g. Megalithic) cannot handle an explicit zero
reserve. Leave the reserve at lightningd's default and add opt-in
flags: experimental-lsps-client-zero-reserve on the client and
experimental-lsps2-zero-reserve on the service.

Also add uniform #[serde(default)] on DatastoreEntry's optional fields
and a regression test pinning that entries persisted without the newer
fields keep deserializing.

Addresses review feedback by @cdecker on ElementsProject#8948 (Megalithic reserve).
@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from 4d47562 to 3e1dce5 Compare August 19, 2026 09:52
@nepet
nepet requested a review from nGoline August 19, 2026 13:34

@nGoline nGoline 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.

Re-reviewed after the 2026-08-19 rebase with more context.

The FSM fixes it: poll_channel_ready's 120s deadline plus check_cltv_timeout bound the wait, and withhold:true means the failure path never broadcasts the funding tx at all.

Two things from my 2026-08-04 review are still open on 3e1dce5 and I've actually found 2 more blocking issues (manager.rs:194, manager.rs:314) (comments in-line).

Comment thread plugins/lsps-plugin/src/core/lsps2/session.rs
Comment thread plugins/lsps-plugin/src/core/lsps2/actor.rs
Comment thread plugins/lsps-plugin/src/core/lsps2/manager.rs
Comment thread plugins/lsps-plugin/src/core/lsps2/manager.rs
Comment thread plugins/lsps-plugin/src/core/lsps2/manager.rs
Comment thread plugins/lsps-plugin/src/core/lsps2/session.rs
@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from 8179c1e to 50e4d6b Compare August 21, 2026 13:34
nepet added a commit to nepet/lightning that referenced this pull request Aug 21, 2026
The UNKNOWN_NEXT_PEER constant carried the value 0x4010 which is not a
valid BOLT4 failure code; unknown_next_peer is PERM|10 = 0x400a. Every
path that was supposed to fail with unknown_next_peer actually sent the
deprecated incorrect_payment_amount code.

Also stop using the permanent unknown_next_peer for retryable failures
(too many parts, fee allocation failure, channel funding failure) as
senders are expected to blocklist the route on permanent errors. These
now fail with temporary_channel_failure; LSPS2 only mandates
unknown_next_peer when the payment cannot cover the opening fee or
valid_until has passed, and requires temporary_channel_failure for a
client disconnect during channel establishment.

Addresses review feedback by @cdecker on ElementsProject#8948.
nepet added a commit to nepet/lightning that referenced this pull request Aug 21, 2026
HTLC ids are a per-channel counter, so MPP parts of one payment
arriving over different incoming channels can share the same id -- the
common case for multi-part payments. The session actor tracked pending
HTLC replies in a map keyed by the bare id, so a colliding part
silently replaced the earlier part's reply channel: the earlier hook
call resolved to a generic continue (failing the part upstream with
WIRE_UNKNOWN_NEXT_PEER), the manager misread the dropped reply as a
dead actor and removed the live session, and forward amounts could be
cross-wired between parts.

Introduce HtlcId as the composite of the incoming channel scid and the
per-channel id, carry it in PaymentPart/ForwardPart and key the
pending-reply map with it.

Addresses review feedback by @cdecker on ElementsProject#8948 (HTLC id ambiguity).
nepet added a commit to nepet/lightning that referenced this pull request Aug 21, 2026
Both sides unconditionally requested a zero channel reserve on JIT
channels: the client's openchannel hook always replied reserve=0 and
the LSP's fundchannel_start always sent reserve=0. Some LSP
implementations (e.g. Megalithic) cannot handle an explicit zero
reserve. Leave the reserve at lightningd's default and add opt-in
flags: experimental-lsps-client-zero-reserve on the client and
experimental-lsps2-zero-reserve on the service.

Also add uniform #[serde(default)] on DatastoreEntry's optional fields
and a regression test pinning that entries persisted without the newer
fields keep deserializing.

Addresses review feedback by @cdecker on ElementsProject#8948 (Megalithic reserve).
@nepet
nepet force-pushed the plugins/lsps2/mpp-fsm branch from 50e4d6b to b052087 Compare August 21, 2026 14:26
nGoline
nGoline previously approved these changes Aug 21, 2026

@nGoline nGoline 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.

Ack!

madelinevibes pushed a commit to nepet/lightning that referenced this pull request Aug 26, 2026
The UNKNOWN_NEXT_PEER constant carried the value 0x4010 which is not a
valid BOLT4 failure code; unknown_next_peer is PERM|10 = 0x400a. Every
path that was supposed to fail with unknown_next_peer actually sent the
deprecated incorrect_payment_amount code.

Also stop using the permanent unknown_next_peer for retryable failures
(too many parts, fee allocation failure, channel funding failure) as
senders are expected to blocklist the route on permanent errors. These
now fail with temporary_channel_failure; LSPS2 only mandates
unknown_next_peer when the payment cannot cover the opening fee or
valid_until has passed, and requires temporary_channel_failure for a
client disconnect during channel establishment.

Addresses review feedback by @cdecker on ElementsProject#8948.
madelinevibes pushed a commit to nepet/lightning that referenced this pull request Aug 26, 2026
HTLC ids are a per-channel counter, so MPP parts of one payment
arriving over different incoming channels can share the same id -- the
common case for multi-part payments. The session actor tracked pending
HTLC replies in a map keyed by the bare id, so a colliding part
silently replaced the earlier part's reply channel: the earlier hook
call resolved to a generic continue (failing the part upstream with
WIRE_UNKNOWN_NEXT_PEER), the manager misread the dropped reply as a
dead actor and removed the live session, and forward amounts could be
cross-wired between parts.

Introduce HtlcId as the composite of the incoming channel scid and the
per-channel id, carry it in PaymentPart/ForwardPart and key the
pending-reply map with it.

Addresses review feedback by @cdecker on ElementsProject#8948 (HTLC id ambiguity).
madelinevibes pushed a commit to nepet/lightning that referenced this pull request Aug 26, 2026
Both sides unconditionally requested a zero channel reserve on JIT
channels: the client's openchannel hook always replied reserve=0 and
the LSP's fundchannel_start always sent reserve=0. Some LSP
implementations (e.g. Megalithic) cannot handle an explicit zero
reserve. Leave the reserve at lightningd's default and add opt-in
flags: experimental-lsps-client-zero-reserve on the client and
experimental-lsps2-zero-reserve on the service.

Also add uniform #[serde(default)] on DatastoreEntry's optional fields
and a regression test pinning that entries persisted without the newer
fields keep deserializing.

Addresses review feedback by @cdecker on ElementsProject#8948 (Megalithic reserve).
@madelinevibes
madelinevibes force-pushed the plugins/lsps2/mpp-fsm branch from b052087 to 85fb96b Compare August 26, 2026 09:08
@daywalker90

Copy link
Copy Markdown
Collaborator

daywalker90 pushed a commit to nepet/lightning that referenced this pull request Sep 4, 2026
The UNKNOWN_NEXT_PEER constant carried the value 0x4010 which is not a
valid BOLT4 failure code; unknown_next_peer is PERM|10 = 0x400a. Every
path that was supposed to fail with unknown_next_peer actually sent the
deprecated incorrect_payment_amount code.

Also stop using the permanent unknown_next_peer for retryable failures
(too many parts, fee allocation failure, channel funding failure) as
senders are expected to blocklist the route on permanent errors. These
now fail with temporary_channel_failure; LSPS2 only mandates
unknown_next_peer when the payment cannot cover the opening fee or
valid_until has passed, and requires temporary_channel_failure for a
client disconnect during channel establishment.

Addresses review feedback by @cdecker on ElementsProject#8948.
daywalker90 pushed a commit to nepet/lightning that referenced this pull request Sep 4, 2026
HTLC ids are a per-channel counter, so MPP parts of one payment
arriving over different incoming channels can share the same id -- the
common case for multi-part payments. The session actor tracked pending
HTLC replies in a map keyed by the bare id, so a colliding part
silently replaced the earlier part's reply channel: the earlier hook
call resolved to a generic continue (failing the part upstream with
WIRE_UNKNOWN_NEXT_PEER), the manager misread the dropped reply as a
dead actor and removed the live session, and forward amounts could be
cross-wired between parts.

Introduce HtlcId as the composite of the incoming channel scid and the
per-channel id, carry it in PaymentPart/ForwardPart and key the
pending-reply map with it.

Addresses review feedback by @cdecker on ElementsProject#8948 (HTLC id ambiguity).
daywalker90 pushed a commit to nepet/lightning that referenced this pull request Sep 4, 2026
Both sides unconditionally requested a zero channel reserve on JIT
channels: the client's openchannel hook always replied reserve=0 and
the LSP's fundchannel_start always sent reserve=0. Some LSP
implementations (e.g. Megalithic) cannot handle an explicit zero
reserve. Leave the reserve at lightningd's default and add opt-in
flags: experimental-lsps-client-zero-reserve on the client and
experimental-lsps2-zero-reserve on the service.

Also add uniform #[serde(default)] on DatastoreEntry's optional fields
and a regression test pinning that entries persisted without the newer
fields keep deserializing.

Addresses review feedback by @cdecker on ElementsProject#8948 (Megalithic reserve).
@daywalker90
daywalker90 force-pushed the plugins/lsps2/mpp-fsm branch from afe8d68 to 9670406 Compare September 4, 2026 09:29
@daywalker90

Copy link
Copy Markdown
Collaborator

I have added a fix for a test flake i found locally. If i can get an ack on that i want to squash the commits to keep bisectability. For the commit message i want to put:

    plugins(lsps2): add LSPS2 MPP payment collection with session FSM

    Implements an FSM-based session manager for LSPS2 payment collection,
    covering channel discovery, session persistence and restart recovery,
    forwarding/HTLC handling, and channel reserve management.

    - add session FSM for LSPS2 payment collection
    - add session actor with action executor boundary
    - add session manager and unify HTLC handling
    - add integration tests for session lifecycle
    - add restart recovery for session persistence
    - simplify DatastoreProvider to focused trait
    - add EventSink trait for session event notification
    - decouple lsps2 crate from CLN-specific types
    - simplify actor and session manager
    - make collect timeout dev config
    - route recovered sessions through forward_event path
    - adding list_finalized_sessions to the DatastoreProvider
    - key HTLCs by (incoming channel, htlc id)
    - forward replayed HTLCs to recovered sessions
    - prevent double channel funding for one payment hash
    - fail held HTLCs before cltv expiry, not after
    - spawn only one channel-poll task per session
    - harden recovery loop and fee-overflow handling
    - advertise client_trusts_lsp and validate payment size range
    - make channel reserve opt-in and pin datastore compat
    - make finalize_session idempotent
    - only expire offers when starting a new session
    - always release funding inputs when abandoning
    - only abandon session once no forwarded parts remain
    - pytest: cover finalize_session over an existing finalized entry
    - docs: add experimental lsps options to config
    - Add u16 zero len to TEMPORARY_CHANNEL_FAILURE
    - route parts by scid, not payment hash alone
    - fund at most one channel per jit scid
    - monitor recovered sessions for channel death
    - release the channel when recovery finds all forwards failed
    - bound variable-amount payments by the policy size range

    Changelog-Experimental: LSPS2 session state machine for JIT channels

Does that sound good?

@daywalker90
daywalker90 force-pushed the plugins/lsps2/mpp-fsm branch from 9670406 to b38d147 Compare September 4, 2026 11:36
@daywalker90
daywalker90 enabled auto-merge (rebase) September 4, 2026 11:37
Implements an FSM-based session manager for LSPS2 payment collection,
covering channel discovery, session persistence and restart recovery,
forwarding/HTLC handling, and channel reserve management.

- add session FSM for LSPS2 payment collection
- add session actor with action executor boundary
- add session manager and unify HTLC handling
- add integration tests for session lifecycle
- add restart recovery for session persistence
- simplify DatastoreProvider to focused trait
- add EventSink trait for session event notification
- decouple lsps2 crate from CLN-specific types
- simplify actor and session manager
- make collect timeout dev config
- route recovered sessions through forward_event path
- adding list_finalized_sessions to the DatastoreProvider
- key HTLCs by (incoming channel, htlc id)
- forward replayed HTLCs to recovered sessions
- prevent double channel funding for one payment hash
- fail held HTLCs before cltv expiry, not after
- spawn only one channel-poll task per session
- harden recovery loop and fee-overflow handling
- advertise client_trusts_lsp and validate payment size range
- make channel reserve opt-in and pin datastore compat
- make finalize_session idempotent
- only expire offers when starting a new session
- always release funding inputs when abandoning
- only abandon session once no forwarded parts remain
- pytest: cover finalize_session over an existing finalized entry
- docs: add experimental lsps options to config
- Add u16 zero len to TEMPORARY_CHANNEL_FAILURE
- route parts by scid, not payment hash alone
- fund at most one channel per jit scid
- monitor recovered sessions for channel death
- release the channel when recovery finds all forwards failed
- bound variable-amount payments by the policy size range

Changelog-Experimental: LSPS2 session state machine for JIT channels
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants