Skip to content

BOLT11 DuplicatePayment triggers on-chain fallback in UnifiedPayment::send #1033

Description

@ben-kaufman

Summary

UnifiedPayment::send falls back to the on-chain payment method after any BOLT11 error, including Error::DuplicatePayment.

When the BOLT11 invoice is already pending or succeeded, Bolt11Payment correctly rejects another attempt with DuplicatePayment. However, UnifiedPayment treats this as a failed Lightning method and proceeds to the on-chain address.

As a result, retrying the same unified BIP21 payment can pay the recipient twice: once over Lightning and once on-chain.

Relevant code

Bolt11Payment::send_internal returns DuplicatePayment when the invoice is already pending or succeeded:

let payment_hash = invoice.payment_hash();
let payment_id = PaymentId(invoice.payment_hash().0);
if let Some(payment) = self.payment_store.get(&payment_id) {
if payment.status == PaymentStatus::Pending
|| payment.status == PaymentStatus::Succeeded
{
log_error!(self.logger, "Payment error: an invoice must not be paid twice.");
return Err(Error::DuplicatePayment);
}

UnifiedPayment::send does not inspect the BOLT11 error and continues to the on-chain payment method:

PaymentMethod::LightningBolt11(invoice) => {
let invoice = maybe_wrap(invoice.clone());
let payment_result = self.bolt11_invoice.send(&invoice, route_parameters)
.map_err(|e| {
log_error!(self.logger, "Failed to send BOLT11 invoice: {:?}. This is part of a unified payment. Falling back to the on-chain transaction.", e);
e
});
if let Ok(payment_id) = payment_result {
return Ok(UnifiedPaymentResult::Bolt11 { payment_id });
}
},
PaymentMethod::OnChain(address) => {
let amount = resolved.onchain_payment_amount().ok_or_else(|| {
log_error!(self.logger, "No amount specified. Aborting the payment.");
Error::InvalidAmount
})?;
let amt_sats = amount.sats().map_err(|_| {
log_error!(
self.logger,
"Amount in sats returned an error. Aborting the payment."
);
Error::InvalidAmount
})?;
let txid = self
.onchain_payment
.send_to_address_inner(&address, amt_sats, None)
.await?;
return Ok(UnifiedPaymentResult::Onchain { txid });

Reproduction

  1. Construct a BIP21 URI containing:

    • A valid BOLT11 invoice.
    • A valid on-chain address and amount.
    • No BOLT12 offer, or ensure the BOLT12 method is unavailable.
  2. Ensure the payer has sufficient Lightning outbound liquidity and sufficient on-chain funds.

  3. Call:

node.unified_payment().send(&uri, None, None).await
  1. Confirm that the result is UnifiedPaymentResult::Bolt11 and that the Lightning payment is pending or succeeded.

  2. Call UnifiedPayment::send again with the same URI.

Actual behavior

The second BOLT11 attempt returns Error::DuplicatePayment internally. UnifiedPayment::send ignores the error type, falls back to the on-chain method, broadcasts a transaction, and returns:

UnifiedPaymentResult::Onchain { txid }

The recipient may therefore receive both the Lightning payment and the on-chain payment.

Expected behavior

Error::DuplicatePayment should be returned to the caller and should not trigger fallback to another payment method.

Fallback remains appropriate for errors that establish that the Lightning payment could not be initiated, but DuplicatePayment indicates that the same invoice is already pending or succeeded.

Impact

This can occur during normal retry handling:

  • The Lightning payment is initiated successfully.
  • The application does not receive the successful response because of a network failure, RPC timeout, application crash, or restart.
  • The user or application retries the same unified payment.
  • The retry causes an on-chain payment in addition to the existing Lightning payment.

This is particularly relevant for API-based integrations such as ldk-server, where a caller may be unable to distinguish between “the request was not processed” and “the payment was initiated but the response was lost.”

Suggested fix

Treat Error::DuplicatePayment as terminal in the unified-payment flow:

match self.bolt11_invoice.send(&invoice, route_parameters) {
    Ok(payment_id) => {
        return Ok(UnifiedPaymentResult::Bolt11 { payment_id });
    },
    Err(Error::DuplicatePayment) => {
        return Err(Error::DuplicatePayment);
    },
    Err(error) => {
        // Fall back only when it is safe and appropriate to do so.
    },
}

It may also be worth reviewing other Lightning errors and separating them into:

  • Errors for which fallback is safe.
  • Errors indicating that a payment already exists or may have been initiated, for which fallback must stop.

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