Thanks for the go-ahead on reviving publsp. Following up on our conversation (and the two small PRs I just opened, #1 and #2), here's the substantive one: a concrete root-cause + fix proposal behind the exact problem you flagged in your last commit:
07d5891 — "LSP push amounts disabled for improved security but griefing attacks still possible where client can request channel open but payment for inbound doesn't settle."
Disabling push_sat (2ec19ad, 07d5891) closed the acute case (handing the client free spendable balance), but the griefing surface you described survives it, because the LSP still performs an irreversible on-chain channel open before it has irrevocably claimed the Lightning payment, and it holds the payment across the funding confirmation. Below is how I read it and a proposed direction — all up for discussion, and I'm happy to open the PR for whichever approach you prefer.
Root cause A — the hold spans an on-chain confirmation with no CLTV safety margin
The order path (publsp/marketplace/lsp.py, OrderHandler):
process_payment_and_channel_open waits for the hodl invoice to reach HOLD/ACCEPTED (_payment_listener), i.e. the incoming HTLC is locked in but not settled.
- It then runs
_channel_open_listener, which calls open_channel and only calls settle_hodl_invoice once it sees ChannelState.OPEN (lsp.py:461-502).
open_channel leaves min_confs/target_conf at LND defaults (ln/lnd.py:604-619), so OPEN only arrives after the funding tx confirms — potentially many blocks.
Nothing checks that the funding tx will confirm inside the remaining CLTV of the accepted inbound HTLC. If confirmation is slower than that window (fee spike, congestion, a low fee estimate), the upstream HTLC times out and is failed/force-closed back to the payer — the payment never settles, yet the LSP has already broadcast (and paid for) the channel open. Even in the pure-inbound, no-push case that's a direct loss: on-chain fees spent + a channel committed to a peer who paid nothing.
Contributing detail: subscribe_to_hodl_invoice currently reads only payment["state"] and discards the invoice's htlcs array (ln/lnd.py:463-473) — so the accept height / expiry height needed to reason about the safe window isn't even captured today.
Root cause B — no fund reservation across concurrent orders
Orders are dispatched fire-and-forget: _listen does asyncio.create_task(self._handle_channel_request(...)) per request (lsp.py:605-609). verify_order_and_connection checks the current UTXO set can cover the order (lsp.py:375-397), but the real spend happens much later (after payment), and nothing is reserved in between. Two orders arriving close together both pass the same check, both collect a held payment, and the second open_channel fails on-chain → that payer's funds sit in a held HTLC until refund. Under any concurrency the LSP oversells and manufactures exactly the "open requested, inbound doesn't settle cleanly" situation.
Failure scenario (concrete)
- Client sends a valid order; LSP validates, connects, issues the hodl invoice.
- Client pays → HTLC
ACCEPTED (held). LSP broadcasts the funding tx.
- Mempool is congested; the funding tx sits unconfirmed past the inbound HTLC's CLTV delta.
- Upstream fails/force-closes the HTLC → payment refunded to the client.
- Channel still opens (or the LSP eats a force-close + on-chain fees). LSP paid, client didn't.
(A malicious client can bias step 3 by choosing thin fee conditions / stalling, and can amplify via concurrent orders per root cause B.)
Proposed direction (for discussion)
1. Bound the hold against the HTLC CLTV — the core fix.
- Capture the accepted HTLC's
accept_height / expiry_height from the invoice subscription (extend subscribe_to_hodl_invoice to surface htlcs, not just state).
- Before broadcasting the open, require
remaining_cltv_blocks − safety_margin >= expected_confirmation_blocks (both configurable). If it doesn't hold, refuse the open and cancel/refund up front rather than broadcasting and hoping.
- Minimize hold time: prefer settling at first confirmation (or an opt-in 0-conf path where the LSP accepts that risk) instead of waiting for deep confirmation, so the window the HTLC must survive is as small as possible.
- Add a hard max-hold timeout that cancels + refunds before the HTLC is at risk, instead of letting it expire uncontrolled.
The fundamental tension worth your view: with a hodl invoice the LSP can't partially claim, so it is structurally exposed for the interval between broadcasting the funding tx and releasing the preimage. The above shrinks and bounds that interval; if you'd prefer a different model for the restart (e.g. requiring the client's HTLC CLTV to exceed a floor, or a settle-then-open ordering with a different guarantee), I'm glad to build to that.
2. Reserve funds per accepted order.
Keep an in-memory reserved-balance ledger; at acceptance, atomically check-and-reserve against spendable − required_reserve − already_reserved, behind a lock so concurrent orders can't double-spend the same UTXOs. Release on completion/failure/expiry. Optionally cap in-flight orders with a semaphore.
3. Regression tests (into tests/).
- Accepted invoice whose HTLC CLTV is too near expiry for the confirmation target → assert no open, clean cancel/refund.
- Two concurrent orders that together exceed available funds → assert only one is accepted (reservation), the other rejected up front (no held-then-refund).
- Open failure after payment held → assert immediate cancel/refund (partly there via
ChannelState.UNKNOWN → cancel_hodl_invoice, worth locking in).
Happy to split this into a design discussion here + a PR, or go straight to a PR against main if the direction looks right to you. Either way, thanks for publsp — the core idea is worth getting over this last hurdle.
🤖 Generated with Claude Code
Thanks for the go-ahead on reviving publsp. Following up on our conversation (and the two small PRs I just opened, #1 and #2), here's the substantive one: a concrete root-cause + fix proposal behind the exact problem you flagged in your last commit:
Disabling
push_sat(2ec19ad,07d5891) closed the acute case (handing the client free spendable balance), but the griefing surface you described survives it, because the LSP still performs an irreversible on-chain channel open before it has irrevocably claimed the Lightning payment, and it holds the payment across the funding confirmation. Below is how I read it and a proposed direction — all up for discussion, and I'm happy to open the PR for whichever approach you prefer.Root cause A — the hold spans an on-chain confirmation with no CLTV safety margin
The order path (
publsp/marketplace/lsp.py,OrderHandler):process_payment_and_channel_openwaits for the hodl invoice to reachHOLD/ACCEPTED(_payment_listener), i.e. the incoming HTLC is locked in but not settled._channel_open_listener, which callsopen_channeland only callssettle_hodl_invoiceonce it seesChannelState.OPEN(lsp.py:461-502).open_channelleavesmin_confs/target_confat LND defaults (ln/lnd.py:604-619), soOPENonly arrives after the funding tx confirms — potentially many blocks.Nothing checks that the funding tx will confirm inside the remaining CLTV of the accepted inbound HTLC. If confirmation is slower than that window (fee spike, congestion, a low fee estimate), the upstream HTLC times out and is failed/force-closed back to the payer — the payment never settles, yet the LSP has already broadcast (and paid for) the channel open. Even in the pure-inbound, no-push case that's a direct loss: on-chain fees spent + a channel committed to a peer who paid nothing.
Contributing detail:
subscribe_to_hodl_invoicecurrently reads onlypayment["state"]and discards the invoice'shtlcsarray (ln/lnd.py:463-473) — so the accept height / expiry height needed to reason about the safe window isn't even captured today.Root cause B — no fund reservation across concurrent orders
Orders are dispatched fire-and-forget:
_listendoesasyncio.create_task(self._handle_channel_request(...))per request (lsp.py:605-609).verify_order_and_connectionchecks the current UTXO set can cover the order (lsp.py:375-397), but the real spend happens much later (after payment), and nothing is reserved in between. Two orders arriving close together both pass the same check, both collect a held payment, and the secondopen_channelfails on-chain → that payer's funds sit in a held HTLC until refund. Under any concurrency the LSP oversells and manufactures exactly the "open requested, inbound doesn't settle cleanly" situation.Failure scenario (concrete)
ACCEPTED(held). LSP broadcasts the funding tx.(A malicious client can bias step 3 by choosing thin fee conditions / stalling, and can amplify via concurrent orders per root cause B.)
Proposed direction (for discussion)
1. Bound the hold against the HTLC CLTV — the core fix.
accept_height/expiry_heightfrom the invoice subscription (extendsubscribe_to_hodl_invoiceto surfacehtlcs, not juststate).remaining_cltv_blocks − safety_margin >= expected_confirmation_blocks(both configurable). If it doesn't hold, refuse the open and cancel/refund up front rather than broadcasting and hoping.The fundamental tension worth your view: with a hodl invoice the LSP can't partially claim, so it is structurally exposed for the interval between broadcasting the funding tx and releasing the preimage. The above shrinks and bounds that interval; if you'd prefer a different model for the restart (e.g. requiring the client's HTLC CLTV to exceed a floor, or a settle-then-open ordering with a different guarantee), I'm glad to build to that.
2. Reserve funds per accepted order.
Keep an in-memory reserved-balance ledger; at acceptance, atomically check-and-reserve against
spendable − required_reserve − already_reserved, behind a lock so concurrent orders can't double-spend the same UTXOs. Release on completion/failure/expiry. Optionally cap in-flight orders with a semaphore.3. Regression tests (into
tests/).ChannelState.UNKNOWN → cancel_hodl_invoice, worth locking in).Happy to split this into a design discussion here + a PR, or go straight to a PR against
mainif the direction looks right to you. Either way, thanks for publsp — the core idea is worth getting over this last hurdle.🤖 Generated with Claude Code