Skip to content

lsps-plugin: keep the client's zero-conf approval per buy until its channel is open - #9716

Open
nepet wants to merge 10 commits into
ElementsProject:masterfrom
nepet:lsps2-client-approval-per-buy
Open

nepet wants to merge 10 commits into
ElementsProject:masterfrom
nepet:lsps2-client-approval-per-buy

Conversation

@nepet

@nepet nepet commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Builds on #9658, which should merge first. Until then, this PR's diff includes its commits.

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

Fixes #9715.

The LSPS2 client accepted a zero-conf channel from an LSP with a single datastore record per LSP, and deleted it in the openchannel hook. So if the open failed after the hook, e.g. because we disconnected before sending funding_signed, the LSP's open for the payer's retry was refused. And two outstanding JIT invoices from the same LSP shared the record, so the second invoice's channel was refused.

This keeps an approval per buy, lsps/client/<lsp_id>/<jit_channel_scid>, until the buy's invoice expires:

  • the openchannel hook allows a zero-conf channel while the LSP has an approval left, but doesn't spend it: open_channel doesn't say which buy it is for
  • an approval is spent once lightningd has saved the channel (channel_opened, which comes before funding_signed), so the LSP still gets one channel per buy
  • if that channel is still waiting for channel_ready when the LSP reconnects (peer_connected), the LSP never got our funding_signed, and the approval is given back for its retry
  • once the channel got going, the spent approval is deleted, when the buy's invoice is paid or on reconnect, so the client still leaves nothing behind after a buy
  • expired approvals, and the record older versions kept, are deleted when the LSP's approvals are next read

The first commit lets the mock tests pay an invoice they already have. The second adds three tests against the mock LSP: the retry after a disconnect before funding_signed, two invoices from the same LSP, and an LSP that opens a second channel for a buy (refused before and after, the test checks it is the zero-conf refusal).

Limits: an LSP that disconnects before sending channel_ready gets the approval back on reconnect, at the cost of a funding output it never broadcasts. And any channel the LSP funds while a buy is outstanding spends an approval, which only errs towards refusing. The openchannel hook and the channel_opened notification run as separate tasks. lightningd refuses a second open while one is in progress, so the LSP's next open can only start once the first channel is saved. An LSP that sends it right away may still have its hook run before the notification spends the approval, and get a second zero-conf channel for the buy. lsps-lsps2-approve now looks up the buy's invoice by payment_hash to take its expiry, so it fails if no such invoice exists. lsps-lsps2-invoice calls it after creating the invoice.

Changelog-Changed: lsps-plugin: the LSPS2 client accepts the JIT channel for a payment retried after a failed open, and for each of several JIT invoices from the same LSP.

nepet added 9 commits October 26, 2026 16:14
Every LSPS2 client test should end by checking the same invariants on
what the client did, whatever the mock service misbehaved in. Add
tests/lsps2_oracles.py with the first two:

- Amount: a paid invoice received at least its amount minus the
  opening fee it owes (none for payments after the session), and the
  client holds no more in channels its peers opened than its paid
  invoices received.
- Identity: invoices route only through known channels or through
  the LSP on a jit_channel_scid it issued to this client, and the only
  channel used before its funding confirms is the session's.

The checks work on an Observation snapshot of plain data, gathered
from the client and the mock's state, so they are unit tested on hand-
written runs. For that the mock now timestamps its events, reports the
params it issued and remembers which payment opened each session.

The honest JIT tests now run the oracles.
Expired, contradictory or modified params must never lead to a
payment. Two checks:

- Every lsps2.buy the client sent uses params exactly as the LSP issued
  them, before valid_until, with usable payment size limits, a payment
  size the fee does not eat up, and a promise of at most 512 bytes of
  printable ASCII without '"' or '\'.
- Every LSPS2 invoice follows the buy result the client was sent: the
  lsp_cltv_expiry_delta in a route hint without fees, an expiry no
  later than valid_until and the payment size as amount.
The last oracle for the LSPS2 client tests: the LSPS client plugin is
still running and never crashed, no HTLC is left pending in the
client's channels, and the client sent no more than 20 LSPS requests.
check_oracles first waits for the client's HTLCs to settle, so that a
check does not race a payment still in flight.
A client that accepts a part whose extra_fee does not match what was
deducted from it breaks bLIP-52 even when the total deducted happens
to be the right fee, and the amount oracle did not see that. Check
that each part of a paid invoice arrived short by exactly its
extra_fee and that the extra_fees add up to at most what the payment
owes.
The LSPS2 service is to leave Core Lightning, so the client tests need
a counterpart that does not depend on it. Run each client test that
uses the in-tree service against both it and the mock service, which
serves the same opening fee menu as the policy plugin. Nothing in the
tests changes for the in-tree service; dropping it later only drops
its parameter.

With the mock, the JIT channel tests also check the oracles. The
non-approved zero-conf test makes its invoice itself, so the checks on
how the client makes invoices do not apply there.
The MPP+fixed-invoice test ran the in-tree service with an earlier
Python mock taking over its HTLC handling, because the service does not
support MPP. Run it against the mock service instead, which collects
the parts, opens the channel and spreads the opening fee over the parts
itself, and remove the earlier mock.

The fee stays at 1000000 msat, now computed from a proportional rate,
and the test gains the checks of the mock's own MPP test, which it
replaces: how the fee is spread over the parts, the invoice status and
the oracles.
The tests of the LSPS2 client against a misbehaving LSP go into their
own file and need the same helpers to set up nodes and talk to the
mock service. Move them out of test_cln_lsps.py, unchanged.
Split lsps2_mock_pay() out of lsps2_mock_pay_var_invoice(), so that a
test can pay the same invoice twice, or several invoices.

Changelog-None

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

In on_channel_opened, why spend the approval that expires first, and not keep a small "pending opens per peer" set filled by the openchannel hook? also on_lsps_lsps2_approve, on_channel_opened and on_peer_connected each rebuild rpc_path and call ClnRpc::new. maybe we could add a small State::rpc() helper, it could remove about 5 copies


let dir = p.configuration().lightning_dir;
let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;

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.

what do you think about taking approvals.lock() as the first await in on_channel_opened (before ClnRpc::new). That makes the window smaller. it still does not guarantee order on a multi thread runtime. if a full fix is wanted, track "opens in progress" by peer id in the hook and clear them in the notification?

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.

Done: on_channel_opened takes the lock before connecting to lightningd. As you say, it narrows the window without closing it. See the summary for why I didn't go for the pending-opens set.

key: entry.key,
record,
}),
_ => stale.push(entry.key),

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.

maybe treat a record without expires_at as one unspent approval with a short default expiry, instead of dropping it?

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'd rather not. The old record only matters if the node is upgraded while a JIT invoice is outstanding and its LSP hasn't opened yet, and that invoice lives at most until the fee params' valid_until. Migrating it means guessing an expiry, since the old record doesn't say which invoice it's for. It also means moving it from lsps/client/<lsp> to a key below it. For an experimental option, the cost of dropping it is one failed JIT payment that needs a new invoice, which seems better than carrying migration code. Happy to add it if you feel strongly.

// The buy's channel is open: its spent approval is done with.
if let Some(lsp_id) = lsp_id {
let _lock = p.state().approvals.lock().await;
if let Err(e) = settle_spent_approvals(&mut cln_client, &lsp_id, false).await {

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.

an approval given back can outlive the buy?

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.

Yes. Say the LSP disconnects after our funding_signed but before channel_ready. On reconnect the channel is still waiting for channel_ready, so the approval is given back. If channel_ready then goes through on that same channel and pays the invoice, the approval is unspent and stays until the invoice expires: one more zero-conf channel the LSP could open. Fixed: a given-back approval remembers the channels it came from (given_back_from) and is deleted like a spent one once one of them gets going. test_lsps2_mock_ready_disconnect covers it (it fails without the fix), and settle_deletes_approvals_whose_channel_got_going covers the cases.

start: None,
})
.await?;
let invoice = invoices

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.

lsps-lsps2-approve now fails if the invoice does not exist. but today lsps-lsps2-invoice is its only caller, and it calls approve after creating the invoice, so it is fine

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.

Right, that's intended, and the PR description mentions it. If lsps-lsps2-approve needs to work on its own later, it can take an explicit expiry then.

expires_at: u64,
/// The funding txid of the channel the approval was spent on, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
funding_txid: Option<String>,

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.

why String and not a Txid?

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.

cln-rpc models txids as String (listpeerchannels' funding_txid is Option<String>), and the channel_opened notification gives us a string too. We only store the txid and compare it with listpeerchannels, so a Txid would mean parsing on both sides for nothing. Serialized, it's the same hex string, so it's easy to switch if cln-rpc moves to Txid.

…hannel is open

The LSPS2 client approved zero-conf channels from an LSP with a single
datastore record, lsps/client/<lsp_id>, and deleted it in the
openchannel hook. So:

- if the open failed after the hook, e.g. because we disconnected
  before sending funding_signed, the LSP's open for the payer's retry
  was refused ("You required zeroconf, but you're not on our
  allowlist"), and the buy could not be paid;
- two outstanding JIT invoices from the same LSP shared the record, so
  the channel for the second one was refused.

Keep an approval per buy instead, lsps/client/<lsp_id>/<jit_channel_scid>,
until the buy's invoice expires. The openchannel hook allows a zero-conf
channel from the LSP while one of its approvals is left, but no longer
spends it; open_channel doesn't say which buy the channel is for anyway.

An approval is spent once lightningd has saved the channel: it sends
channel_opened before funding_signed, so the LSP still gets one channel
per buy. If that channel is still waiting for channel_ready when the LSP
reconnects, the LSP never got our funding_signed, and the approval is
given back for its retry. Once the channel got going, the spent
approval is deleted: when the buy's invoice is paid, or on reconnect.
An approval given back remembers the channel it came from, and is
deleted the same way if that channel gets going after all.

Expired approvals, and the record older versions kept, are deleted when
the LSP's approvals are next read.

Changelog-Experimental: lsps-plugin: the LSPS2 client accepts the JIT channel for a payment retried after a failed open, and for each of several JIT invoices from the same LSP.
@nepet
nepet force-pushed the lsps2-client-approval-per-buy branch from a697d20 to ead247a Compare November 12, 2026 14:53
@nepet

nepet commented Nov 12, 2026

Copy link
Copy Markdown
Member Author

@Andezion Thanks for your review!

On which approval to spend: open_channel doesn't say which buy it's for, so any approval does for the count. Spending the one that expires first means the approvals left always last at least as long as the invoices still outstanding. If buy B's channel comes first and spends A's approval, A is left with B's, which expires later, never the other way round.

A pending-opens set filled in the openchannel hook would close the hook/notification race. But something has to clear an entry when the open fails after the hook and before lightningd saves the channel, which is the case this PR fixes. Nothing notifies us of that failure. We'd have to clear on disconnect or reconnect and rely on every failure path disconnecting. Otherwise the payer's retry is refused again. So I took your other suggestion instead: on_channel_opened now takes the lock first. Its task is spawned a full round trip (our funding_signed, then the LSP's next open_channel) before the next hook's task, and tokio's Mutex is FIFO. So the notification gets the lock first unless it isn't polled for that whole time. I'd rather keep that documented window than add state that can get stuck.

State::rpc(): agreed, but most of the copies predate this PR (getinfo, buy, approve, invoice, htlc_accepted, invoice_payment, listprotocols). This PR adds two. I'll do it in a small follow-up so this one stays focused.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LSPS2 client: a failed JIT channel open spends the zero-conf approval, so the retry is refused

2 participants