Skip to content

BOLT12 send should accept a caller PaymentId; PaymentStore insert after ChannelManager accept can hide in-flight payments #1075

Description

@vincenzopalazzo

Summary

Integrators that must poll payment status (e.g. Cashu CDK melts) need two things LDK-Node currently does not provide:

  1. A PaymentId before Bolt12Payment::send can dispatch, so a lost/PersistenceFailed send() is still look-up-able.
  2. 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:

  1. channel_manager.pay_for_* succeeds → payment is live in LDK
  2. payment_store.insert(Pending) → only now Node::payment can see it
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions