Skip to content

Reconcile outbound payments between LDK state and PaymentStore on restart #969

Description

@joostjager

Problem

ldk-node stores outbound payment records independently from LDK's ChannelManager and ChannelMonitor persistence. That creates two restart-time split-brain cases: LDK can know about an outbound payment that is missing from ldk-node's PaymentStore, or PaymentStore can contain a Pending outbound payment that LDK no longer knows about.

The relevant BOLT11 send path is src/payment/bolt11.rs, Bolt11Payment::send_internal:

  1. ldk-node derives the local PaymentId from the invoice payment hash with PaymentId(invoice.payment_hash().0).
  2. It checks self.payment_store.get(&payment_id) and blocks only if the existing record is Pending or Succeeded.
  3. It calls self.channel_manager.pay_for_bolt11_invoice(invoice, payment_id, amount_msat, optional_params).
  4. Only after pay_for_bolt11_invoice returns Ok(()), it constructs a PaymentDetails with PaymentDirection::Outbound and PaymentStatus::Pending.
  5. It persists that record with self.runtime.block_on(self.payment_store.insert(payment))?.

Because the LDK payment start and the ldk-node payment-record write are separate persistence streams, a crash or store error around this boundary can leave only one side durable.

On startup, src/builder.rs reads stored payment records with read_all_objects(... PAYMENT_INFO_PERSISTENCE_...) and builds PaymentStore::new(payments, ...). That reconstructs ldk-node's payment view from the ldk-node payment namespace. Node::payment, Node::list_payments, and Node::list_payments_with_filter in src/lib.rs all read from PaymentStore.

I did not find a startup pass that compares ChannelManager::list_recent_payments() against PaymentStore. Without that reconciliation, ldk-node appears to trust whichever side survived in the ldk-node payment namespace and does not repair disagreements with LDK's recent outbound payment state.

Consequence

If LDK knows about a payment but PaymentStore does not, the payment may be in flight or may later complete while remaining invisible to ldk-node payment APIs. This is primarily a payment-history/accounting gap rather than an LDK channel-fund-safety claim. It also feeds into duplicate-payment risk if duplicate protection later depends on a completed record that was never written.

If PaymentStore has a Pending outbound payment but LDK does not, the API can show a permanently stuck pending payment. The same local pending record can also block a retry because Bolt11Payment::send_internal treats Pending records as duplicates before calling LDK.

Related Open Issues

#171 is a partial duplicate. It asks to retry pending payments on restart and points at ChannelManager::list_recent_payments(), so it overlaps with the PaymentStore-pending-but-LDK-missing direction. It does not appear to cover the opposite direction, where LDK has a recent outbound payment but ldk-node has no PaymentStore record, and it does not spell out a general reconciliation contract between the two stores.

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