Add total-MPP-value storage in pending payments
What changed, and why it matters
This commit fixes a bug where retrying a multi-part Lightning payment could fail because the original 'total payment amount' setting stored in the onion was lost and replaced with a default value. The fix stores that onion total directly in the pending payment record so retries preserve it. There is no direct evidence this is exploitable by an attacker; it appears to be a reliability/functional bug for legitimate users, especially those splitting payments across multiple wallets.
Treat as a normal bugfix. Reviewers should verify that `onion_total_msat` is correctly propagated in all retry paths and that the serialization fallback behaves as intended. No immediate security response appears warranted based on the commit alone.
Security signals we found
Functional bug in payment retry path could cause payment failure/DoS for legitimate senders
No direct diff evidence of attacker-controlled input leading to loss of funds or memory corruption
Serialization change includes fallback default, reducing downgrade risk
Change relates to MPP total amount consistency, which can affect payment success and fee/routing behavior
Evidence from the diff
The patch adds an onion_total_msat field to PendingOutboundPayment::Retryable and uses it when rebuilding RecipientOnionFields during payment retries. Previously, retries reconstructed RecipientOnionFields from Retryable data, losing any custom total_mpp_amount_msat set in the onion. Serialization is backward-compatible: if onion_total_msat is absent, it defaults to total_msat. The change is confined to lightning/src/ln/outbound_payment.rs.
Changed components
lightning/src/ln/outbound_payment.rsPendingOutboundPayment::RetryableOutboundPayments retry logicRecipientOnionFields total_mpp_amount_msat handlingInspect captured patch +24 / −2
diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs
index 65cc21a..fa11184 100644
--- a/lightning/src/ln/outbound_payment.rs
+++ b/lightning/src/ln/outbound_payment.rs
@@ -133,6 +133,11 @@ pub(crate) enum PendingOutboundPayment {
pending_fee_msat: Option<u64>,
/// The total payment amount across all paths, used to verify that a retry is not overpaying.
total_msat: u64,
+ /// The total payment amount which is set in the onion.
+ ///
+ /// This is generally equal to [`Self::Retryable::total_msat`] but may differ when making
+ /// payments which are sent MPP from different sources.
+ onion_total_msat: u64,
/// Our best known block height at the time this payment was initiated.
starting_block_height: u32,
remaining_max_total_routing_fee_msat: Option<u64>,
@@ -1656,7 +1661,7 @@ impl OutboundPayments {
match payment.get() {
PendingOutboundPayment::Retryable {
total_msat, keysend_preimage, payment_secret, payment_metadata,
- custom_tlvs, pending_amt_msat, invoice_request, ..
+ custom_tlvs, pending_amt_msat, invoice_request, onion_total_msat, ..
} => {
const RETRY_OVERFLOW_PERCENTAGE: u64 = 10;
let retry_amt_msat = route.get_total_amount();
@@ -1676,7 +1681,7 @@ impl OutboundPayments {
payment_secret: *payment_secret,
payment_metadata: payment_metadata.clone(),
custom_tlvs: custom_tlvs.clone(),
- total_mpp_amount_msat: *total_msat,
+ total_mpp_amount_msat: *onion_total_msat,
};
let keysend_preimage = *keysend_preimage;
let invoice_request = invoice_request.clone();
@@ -1992,6 +1997,7 @@ impl OutboundPayments {
custom_tlvs: recipient_onion.custom_tlvs,
starting_block_height: best_block_height,
total_msat: route.get_total_amount(),
+ onion_total_msat: recipient_onion.total_mpp_amount_msat,
remaining_max_total_routing_fee_msat:
route.route_params.as_ref().and_then(|p| p.max_total_routing_fee_msat),
};
@@ -2699,6 +2705,7 @@ impl OutboundPayments {
pending_amt_msat: path_amt,
pending_fee_msat: Some(path_fee),
total_msat: path_amt,
+ onion_total_msat: path_amt,
starting_block_height: best_block_height,
remaining_max_total_routing_fee_msat: None, // only used for retries, and we'll never retry on startup
}
@@ -2781,6 +2788,21 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment,
(9, custom_tlvs, optional_vec),
(10, starting_block_height, required),
(11, remaining_max_total_routing_fee_msat, option),
+ (12, onion_total_msat, (custom, u64,
+ // Once we get here, `total_msat` will have been read (or we'll fail to read)
+ |read_val: Option<u64>| Ok(read_val.unwrap_or(total_msat.0.unwrap())),
+ |us: &PendingOutboundPayment| {
+ match us {
+ PendingOutboundPayment::Retryable { total_msat, onion_total_msat, .. } => {
+ if total_msat != onion_total_msat {
+ Some(*onion_total_msat)
+ } else {
+ None
+ }
+ },
+ _ => unreachable!(),
+ }
+ })),
(13, invoice_request, option),
(15, bolt12_invoice, option),
(not_written, retry_strategy, (static_value, None)),
Why this scored 43/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.