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
-
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.
-
Ensure the payer has sufficient Lightning outbound liquidity and sufficient on-chain funds.
-
Call:
node.unified_payment().send(&uri, None, None).await
-
Confirm that the result is UnifiedPaymentResult::Bolt11 and that the Lightning payment is pending or succeeded.
-
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.
Summary
UnifiedPayment::sendfalls back to the on-chain payment method after any BOLT11 error, includingError::DuplicatePayment.When the BOLT11 invoice is already pending or succeeded,
Bolt11Paymentcorrectly rejects another attempt withDuplicatePayment. However,UnifiedPaymenttreats 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_internalreturnsDuplicatePaymentwhen the invoice is already pending or succeeded:ldk-node/src/payment/bolt11.rs
Lines 277 to 285 in 5e7250a
UnifiedPayment::senddoes not inspect the BOLT11 error and continues to the on-chain payment method:ldk-node/src/payment/unified.rs
Lines 305 to 335 in 5e7250a
Reproduction
Construct a BIP21 URI containing:
Ensure the payer has sufficient Lightning outbound liquidity and sufficient on-chain funds.
Call:
Confirm that the result is
UnifiedPaymentResult::Bolt11and that the Lightning payment is pending or succeeded.Call
UnifiedPayment::sendagain with the same URI.Actual behavior
The second BOLT11 attempt returns
Error::DuplicatePaymentinternally.UnifiedPayment::sendignores the error type, falls back to the on-chain method, broadcasts a transaction, and returns:The recipient may therefore receive both the Lightning payment and the on-chain payment.
Expected behavior
Error::DuplicatePaymentshould 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
DuplicatePaymentindicates that the same invoice is already pending or succeeded.Impact
This can occur during normal retry handling:
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::DuplicatePaymentas terminal in the unified-payment flow:It may also be worth reviewing other Lightning errors and separating them into: