Manual capture: a hold failed back at its claim deadline stays Pending forever #21

Closed
opened 2026-10-09 15:01:15 +00:00 by thesimplekid · 0 comments
Owner

Summary

LDK Node fails a held (manual-claim) payment back at its claim deadline, but leaves the inbound payment record Pending; it never becomes Failed. satsettle treats only Failed as proof that a hold is gone. So a hold that passed its deadline:

  • keeps appearing in authorizations() as funded (hold_status returns Pending);
  • once selected, never resolves. capture_payment calls Bolt11ClaimForId, which returns OK without funds because claim_funds is a no-op. The status stays Pending and claim() returns Unavailable forever.

The README tells applications to keep their reservation "until the selected intent is definitively Failed", so the resource (for example a username) stays reserved permanently.

Evidence

  • LDK Node 179b4b2 (behind LDK Server 21a903c): LdkEvent::HTLCHandlingFailed is only passed to the LSPS2 service, and no code path marks the inbound payment Failed when held HTLCs time out at claim_deadline. The same is true at f375e4d (LDK Server 08316de).
  • Bolt11Payment::claim_for_id checks only the stored record, preimage and amount before channel_manager.claim_funds(preimage), which does nothing when the HTLCs are gone.
  • Reproduced live in npub.cash on regtest with two LDK Server nodes. A server holding a token without payments:claim reserved a name; the payer's HTLC was failed back after mining past the deadline (the payer saw Failed). The receiving node's record stayed Pending indefinitely.

satsettle at 1e9e7230f3 stores Authorization::claim_deadline but never compares it with the node's block height.

Suggested fix

  • Compare the deadline with the node's height. Treat a hold as failed once the node's best block height (GetNodeInfo, permission node:read) is at or past its claim_deadline. In authorizations(), exclude it and mark it failed. In capture_payment and recovery, mark it Failed instead of retrying. Claiming "will necessarily fail after the claim_deadline has been reached", per LDK Node's docs.
  • Bound unknown deadlines. For holds found through ListPayments catch-up, where there's no event and so no deadline, use a conservative bound such as the current height plus LDK's maximum HTLC lock (2016 blocks). Or refuse to authorize payments whose invoice is already past its payment window, since their funds may already be gone.
  • Document the permission. Add node:read to the documented macaroon permissions.

npub.cash does the first and second; its regtest end-to-end check covers a claim released after mining past the deadline.

## Summary LDK Node fails a held (manual-claim) payment back at its claim deadline, but leaves the inbound payment record `Pending`; it never becomes `Failed`. satsettle treats only `Failed` as proof that a hold is gone. So a hold that passed its deadline: - keeps appearing in `authorizations()` as funded (`hold_status` returns `Pending`); - once selected, never resolves. `capture_payment` calls `Bolt11ClaimForId`, which returns OK without funds because `claim_funds` is a no-op. The status stays `Pending` and `claim()` returns `Unavailable` forever. The README tells applications to keep their reservation "until the selected intent is definitively Failed", so the resource (for example a username) stays reserved permanently. ## Evidence - LDK Node `179b4b2` (behind LDK Server `21a903c`): `LdkEvent::HTLCHandlingFailed` is only passed to the LSPS2 service, and no code path marks the inbound payment `Failed` when held HTLCs time out at `claim_deadline`. The same is true at `f375e4d` (LDK Server `08316de`). - `Bolt11Payment::claim_for_id` checks only the stored record, preimage and amount before `channel_manager.claim_funds(preimage)`, which does nothing when the HTLCs are gone. - Reproduced live in npub.cash on regtest with two LDK Server nodes. A server holding a token without `payments:claim` reserved a name; the payer's HTLC was failed back after mining past the deadline (the payer saw `Failed`). The receiving node's record stayed `Pending` indefinitely. satsettle at `1e9e7230f3` stores `Authorization::claim_deadline` but never compares it with the node's block height. ## Suggested fix - **Compare the deadline with the node's height.** Treat a hold as failed once the node's best block height (`GetNodeInfo`, permission `node:read`) is at or past its `claim_deadline`. In `authorizations()`, exclude it and mark it failed. In `capture_payment` and recovery, mark it `Failed` instead of retrying. Claiming "will necessarily fail after the claim_deadline has been reached", per LDK Node's docs. - **Bound unknown deadlines.** For holds found through `ListPayments` catch-up, where there's no event and so no deadline, use a conservative bound such as the current height plus LDK's maximum HTLC lock (2016 blocks). Or refuse to authorize payments whose invoice is already past its payment window, since their funds may already be gone. - **Document the permission.** Add `node:read` to the documented macaroon permissions. npub.cash does the first and second; its regtest end-to-end check covers a claim released after mining past the deadline.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
thesimplekid/satsettle#21
No description provided.