Summary
Integrators that must poll payment status (e.g. Cashu CDK melts) need two things LDK-Node currently does not provide:
- A
PaymentId before Bolt12Payment::send can dispatch, so a lost/PersistenceFailed send() is still look-up-able.
- A guarantee that once pay has been accepted,
Node::payment does not return None while the payment is in flight.
This is not the CLN listpays race (pathfinding deferred until after pay returns). If send() has already returned Ok(id), an immediate Node::payment(&id) is Some(Pending). The gap is around send(), and it is worse for BOLT12 because the id is generated inside send() and only returned at the end.
Context
Raised while integrating CDK (cdk-ldk-node) as a mint Lightning backend. CDK's MintPayment::check_outgoing_payment must be conservative: Failed/Unpaid are treated as terminal and may return reserved proofs to the user. Empty/None therefore cannot mean unpaid.
Related: #966 (outbound row 1), #969 (restart reconciliation of the same split). This issue is the live API side of that split, plus the missing BOLT12 caller id.
1. BOLT12: no lookup key unless send() returns
ChannelManager::pay_for_offer already takes a caller PaymentId. LDK-Node generates one internally and only returns it after ChannelManager accept and PaymentStore insert:
let payment_id = PaymentId(self.keys_manager.get_secure_random_bytes());
self.channel_manager.pay_for_offer(&offer, None, payment_id, params)?;
self.runtime.block_on(self.payment_store.insert(payment))?;
Ok(payment_id)
If that call never returns (crash, timeout, dropped future) or returns PersistenceFailed after ChannelManager accepted, the integrator has no key. Offer id is not unique (reusable offers). Quote id is not in LDK-Node.
BOLT11 is fine on this axis: PaymentId == payment_hash, known before pay.
Proposed API
Plumb a caller-supplied PaymentId through Bolt12Payment::send / send_using_amount (required, or Option<PaymentId> defaulting to random for back-compat). Duplicate → Error::DuplicatePayment.
CDK (and similar) would persist that id next to the melt quote before calling send().
2. PaymentStore is written after ChannelManager accepts
Both BOLT11 and BOLT12:
channel_manager.pay_for_* succeeds → payment is live in LDK
payment_store.insert(Pending) → only now Node::payment can see it
send() returns Ok(id)
BOLT11 pathfinding is synchronous inside pay_for_bolt11_invoice (find_initial_route before CM inserts the pending outbound). RouteNotFound returns Err and the store gets Failed, not missing. So this is not CLN listpays.
None is still not “never paid”:
| Window |
Node::payment |
Reality |
Before send() |
None |
not started |
BOLT11, inside send(), during pathfinding |
None |
not in CM yet either |
| CM accepted, store insert not done yet |
None |
in flight |
send() returns PersistenceFailed |
often None |
may be in flight |
| Crash between CM accept and store insert |
None |
in flight; BOLT12 also has no id |
send() returned Ok |
Pending |
in flight |
A concurrent BOLT11 check-by-hash while send() is still in pathfinding can also see empty.
Integrators cannot map None → unpaid after an ambiguous send(), and BOLT12 cannot even name the payment without (1).
Proposed fix
Insert PaymentStatus::Pending before calling pay_for_*. On ChannelManager reject, mark Failed (or remove). Combined with (1), a check during/after a lost send() is Pending rather than missing.
Until then, Node::payment returning None must not be documented or treated as authoritative “not paid”.
Out of scope
Summary
Integrators that must poll payment status (e.g. Cashu CDK melts) need two things LDK-Node currently does not provide:
PaymentIdbeforeBolt12Payment::sendcan dispatch, so a lost/PersistenceFailedsend()is still look-up-able.Node::paymentdoes not returnNonewhile the payment is in flight.This is not the CLN
listpaysrace (pathfinding deferred until afterpayreturns). Ifsend()has already returnedOk(id), an immediateNode::payment(&id)isSome(Pending). The gap is aroundsend(), and it is worse for BOLT12 because the id is generated insidesend()and only returned at the end.Context
Raised while integrating CDK (
cdk-ldk-node) as a mint Lightning backend. CDK'sMintPayment::check_outgoing_paymentmust be conservative:Failed/Unpaidare treated as terminal and may return reserved proofs to the user. Empty/Nonetherefore cannot mean unpaid.Related: #966 (outbound row 1), #969 (restart reconciliation of the same split). This issue is the live API side of that split, plus the missing BOLT12 caller id.
1. BOLT12: no lookup key unless
send()returnsChannelManager::pay_for_offeralready takes a callerPaymentId. LDK-Node generates one internally and only returns it after ChannelManager accept andPaymentStoreinsert:If that call never returns (crash, timeout, dropped future) or returns
PersistenceFailedafter ChannelManager accepted, the integrator has no key. Offer id is not unique (reusable offers). Quote id is not in LDK-Node.BOLT11 is fine on this axis:
PaymentId == payment_hash, known beforepay.Proposed API
Plumb a caller-supplied
PaymentIdthroughBolt12Payment::send/send_using_amount(required, orOption<PaymentId>defaulting to random for back-compat). Duplicate →Error::DuplicatePayment.CDK (and similar) would persist that id next to the melt quote before calling
send().2.
PaymentStoreis written after ChannelManager acceptsBoth BOLT11 and BOLT12:
channel_manager.pay_for_*succeeds → payment is live in LDKpayment_store.insert(Pending)→ only nowNode::paymentcan see itsend()returnsOk(id)BOLT11 pathfinding is synchronous inside
pay_for_bolt11_invoice(find_initial_routebefore CM inserts the pending outbound).RouteNotFoundreturnsErrand the store getsFailed, not missing. So this is not CLNlistpays.Noneis still not “never paid”:Node::paymentsend()Nonesend(), during pathfindingNoneNonesend()returnsPersistenceFailedNoneNonesend()returnedOkPendingA concurrent BOLT11 check-by-hash while
send()is still in pathfinding can also see empty.Integrators cannot map
None→ unpaid after an ambiguoussend(), and BOLT12 cannot even name the payment without (1).Proposed fix
Insert
PaymentStatus::Pendingbefore callingpay_for_*. On ChannelManager reject, markFailed(or remove). Combined with (1), a check during/after a lostsend()isPendingrather than missing.Until then,
Node::paymentreturningNonemust not be documented or treated as authoritative “not paid”.Out of scope
PaymentStore(Reconcile outbound payments between LDK state andPaymentStoreon restart #969)PaymentStatusvariants