Skip to content
10 changes: 9 additions & 1 deletion fuzz/src/invoice_request_deser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
// licenses.

use crate::utils::test_logger;
use bitcoin::blockdata::constants::genesis_block;
use bitcoin::Network;
use bitcoin::secp256k1::{self, Keypair, Parity, PublicKey, Secp256k1, SecretKey};
use core::convert::TryFrom;
use core::time::Duration;
use lightning::blinded_path::payment::{
BlindedPaymentPath, Bolt12OfferContext, ForwardTlvs, PaymentConstraints, PaymentContext,
PaymentForwardNode, PaymentRelay, ReceiveTlvs,
Expand Down Expand Up @@ -81,6 +84,9 @@ fn privkey(byte: u8) -> SecretKey {
fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
invoice_request: &InvoiceRequest, secp_ctx: &Secp256k1<T>,
) -> Result<UnsignedBolt12Invoice, Bolt12SemanticError> {
let network = Network::Bitcoin;
let genesis_block = genesis_block(network);

let expanded_key = ExpandedKey::new([42; 32]);
let entropy_source = Randomness {};
let receive_auth_key = ReceiveAuthKey([41; 32]);
Expand All @@ -98,6 +104,7 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
.payer_note()
.map(|s| UntrustedString(s.to_string())),
human_readable_name: None,
recurrence_counter: None,
}
};

Expand Down Expand Up @@ -144,7 +151,8 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
.unwrap();

let payment_hash = PaymentHash([42; 32]);
invoice_request.respond_with(vec![payment_path], payment_hash)?.build()
let now = Duration::from_secs(genesis_block.header.time as u64);
invoice_request.respond_with(vec![payment_path], payment_hash, now)?.build()
}

pub fn invoice_request_deser_test<Out: test_logger::Output>(data: &[u8], out: Out) {
Expand Down
8 changes: 7 additions & 1 deletion fuzz/src/refund_deser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
// licenses.

use crate::utils::test_logger;
use bitcoin::blockdata::constants::genesis_block;
use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1, SecretKey};
use bitcoin::Network;
use core::convert::TryFrom;
use core::time::Duration;
use lightning::blinded_path::payment::{
BlindedPaymentPath, Bolt12RefundContext, ForwardTlvs, PaymentConstraints, PaymentContext,
PaymentForwardNode, PaymentRelay, ReceiveTlvs,
Expand Down Expand Up @@ -67,6 +70,8 @@ fn privkey(byte: u8) -> SecretKey {
fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
refund: &Refund, signing_pubkey: PublicKey, secp_ctx: &Secp256k1<T>,
) -> Result<UnsignedBolt12Invoice, Bolt12SemanticError> {
let network = Network::Bitcoin;
let genesis_block = genesis_block(network);
let entropy_source = Randomness {};
let receive_auth_key = ReceiveAuthKey([41; 32]);
let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
Expand Down Expand Up @@ -109,7 +114,8 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
.unwrap();

let payment_hash = PaymentHash([42; 32]);
refund.respond_with(vec![payment_path], payment_hash, signing_pubkey)?.build()
let now = Duration::from_secs(genesis_block.header.time as u64);
refund.respond_with(vec![payment_path], payment_hash, signing_pubkey, now)?.build()
}

pub fn refund_deser_test<Out: test_logger::Output>(data: &[u8], out: Out) {
Expand Down
189 changes: 182 additions & 7 deletions lightning/src/ln/channelmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ use crate::chain::transaction::{OutPoint, TransactionData};
use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch};
use crate::events::{
self, ClosureReason, Event, EventHandler, EventsProvider, HTLCHandlingFailureType,
InboundChannelFunds, PaymentFailureReason, ReplayEvent,
InboundChannelFunds, PaymentFailureReason, PaymentPurpose, ReplayEvent,
};
use crate::events::{FundingInfo, PaidBolt12Invoice};
use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight;
Expand Down Expand Up @@ -93,9 +93,11 @@ use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache;
use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow};
use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice};
use crate::offers::invoice_error::InvoiceError;
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer};
use crate::offers::invoice_request::{
InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer,
};
use crate::offers::nonce::Nonce;
use crate::offers::offer::{Offer, OfferFromHrn};
use crate::offers::offer::{Offer, OfferFromHrn, RecurrenceData, RecurrenceFields};
use crate::offers::parse::Bolt12SemanticError;
use crate::offers::refund::Refund;
use crate::offers::static_invoice::StaticInvoice;
Expand Down Expand Up @@ -2671,6 +2673,20 @@ pub struct ChannelManager<
#[cfg(not(test))]
flow: OffersMessageFlow<MR, L>,

/// Tracks all active recurrence sessions for this node.
///
/// Each entry is keyed by the payer’s `payer_signing_pubkey` from the
/// initial `invoice_request`. The associated `RecurrenceData` stores
/// everything the payee needs to validate incoming `invoice_request`s
/// and generate invoices for the appropriate recurrence period.
///
/// This is used by the payee to:
/// - verify the correctness of each incoming `invoice_request`
/// (period offset, counter, basetime, etc.)
/// - ensure continuity across periods
/// - maintain recurrence state until cancellation or completion.
active_recurrence_sessions: Mutex<HashMap<PublicKey, RecurrenceData>>,
Comment on lines +2676 to +2688
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consider adding a cleanup mechanism for stale recurrence sessions.

The active_recurrence_sessions map will grow indefinitely as payers create recurrence sessions. There's no mechanism to remove completed, cancelled, or expired sessions. Over time, this could lead to memory bloat, especially for nodes serving many recurring offers.

Consider implementing cleanup logic that:

  • Removes sessions when recurrence reaches its limit (if specified)
  • Removes sessions after a timeout period of inactivity
  • Provides an API for manual session cleanup

Would you like me to suggest an implementation for session cleanup logic?

🤖 Prompt for AI Agents
In lightning/src/ln/channelmanager.rs around lines 2676 to 2688,
active_recurrence_sessions currently has no cleanup and will grow unbounded; add
session expiration and explicit removal points: extend RecurrenceData with
last_activity_at and optional max_occurrences/expiry_timestamp, update code
paths to remove the entry when a recurrence completes or is cancelled, and spawn
a periodic background pruning task (using the existing runtime/tokio) that locks
the Mutex, iterates the HashMap, and removes entries that are expired or
inactive beyond a configured timeout; also expose a public API method to
manually remove a session by payer_public_key and ensure tests cover automatic
pruning, manual removal, and removal on completion.


/// See `ChannelManager` struct-level documentation for lock order requirements.
#[cfg(any(test, feature = "_test_utils"))]
pub(super) best_block: RwLock<BestBlock>,
Expand Down Expand Up @@ -3960,6 +3976,8 @@ where
router,
flow,

active_recurrence_sessions: Mutex::new(new_hash_map()),

best_block: RwLock::new(params.best_block),

outbound_scid_aliases: Mutex::new(new_hash_set()),
Expand Down Expand Up @@ -9498,6 +9516,32 @@ This indicates a bug inside LDK. Please report this error at https://github.com/
payment_id,
durable_preimage_channel,
}) = payment {
// At this point, the payment has been successfully claimed. If it belongs
// to a recurring offer, we can safely advance the recurrence state.

match &purpose {
PaymentPurpose::Bolt12OfferPayment {
payment_context: Bolt12OfferContext {
invoice_request: InvoiceRequestFields {
payer_signing_pubkey,
recurrence_counter: Some(paid_counter),
..
},
..
},
..
} => {
let mut sessions = self.active_recurrence_sessions.lock().unwrap();

if let Some(data) = sessions.get_mut(payer_signing_pubkey) {
if data.next_payable_counter == *paid_counter {
data.next_payable_counter += 1;
}
}
},
_ => {}
}
Comment on lines +9522 to +9543
Copy link

@coderabbitai coderabbitai bot Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Recurrence counter advancement logic is fragile and doesn't handle edge cases.

The current implementation has several issues:

  1. Out-of-order payments: The condition data.next_payable_counter == *paid_counter means if payment for counter 1 is claimed before counter 0, the state won't advance correctly. This could happen due to payment timing or routing delays.

  2. No duplicate claim protection: If the same payment is claimed multiple times (e.g., due to retries or bugs), the counter could advance incorrectly.

  3. Silent failures: When the equality check fails, no error is logged or raised, making debugging difficult.

Consider:

  • Using a more robust state machine that tracks which counters have been paid (e.g., a set or bitmap)
  • Adding validation that paid_counter is within expected bounds
  • Logging when unexpected counter values are encountered
  • Ensuring idempotency for payment claims
🤖 Prompt for AI Agents
In lightning/src/ln/channelmanager.rs around lines 9522-9543, the current
equality check for advancing next_payable_counter is fragile (fails on
out-of-order payments, duplicates, and silently ignores unexpected counters);
change the logic to validate paid_counter bounds, make claim processing
idempotent by storing paid counters (e.g., a HashSet or bitmap) in the session
data, on receiving a paid_counter: if out of bounds log and reject, if already
present log duplicate and return, otherwise insert it and then advance
next_payable_counter in a loop while the next_payable_counter exists in the
paid-set; add logging for unexpected values and ensure proper locking and early
returns so repeated claims don't double-advance the counter.

Copy link
Owner Author

@shaavan shaavan Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai isn't it idempotent? We update payable_counter, only when paid counter matches. If someone makes a duplicate claim, payable_counter, and paid counter won't match, because paid counter's already updated, right?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shaavan, you're absolutely right! I'm replying to your latest comment without the full context of the earlier conversation, but your analysis of the idempotency is correct.

The code is indeed idempotent with respect to duplicate claims because of the guard condition data.next_payable_counter == *paid_counter. Here's why:

  1. First successful claim: If next_payable_counter = 5 and paid_counter = 5, the condition matches, and next_payable_counter is incremented to 6.

  2. Duplicate claim: If the same payment is claimed again with paid_counter = 5, the condition next_payable_counter == paid_counter will fail (6 ≠ 5), so no update occurs.

This ensures that duplicate claims for the same counter value won't cause the state to advance incorrectly. The counter only advances once per unique recurrence payment, which is the desired behavior for idempotency.


let event = events::Event::PaymentClaimed {
payment_hash,
purpose,
Expand Down Expand Up @@ -12777,6 +12821,32 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => {
Ok(builder.into())
}

/// Creates an [`OfferBuilder`] for a recurring offer.
///
/// This behaves like [`Self::create_offer_builder`] but additionally embeds
/// the recurrence TLVs defined in `recurrence_fields`.
///
/// Use this when constructing subscription-style offers where each invoice
/// request must correspond to a specific recurrence period. The provided
/// [`RecurrenceFields`] specify:
/// - how often invoices may be requested,
/// - when the first period begins,
/// - optional paywindows, and
/// - optional period limits.
///
/// Refer to [`Self::create_offer_builder`] for notes on privacy,
/// requirements, and potential failure cases.
pub fn create_offer_builder_with_recurrence(
&$self,
recurrence_fields: RecurrenceFields
) -> Result<$builder, Bolt12SemanticError> {
let builder = $self.flow.create_offer_builder_with_recurrence(
&*$self.entropy_source, recurrence_fields, $self.get_peers_for_blinded_path()
)?;

Ok(builder.into())
}

/// Same as [`Self::create_offer_builder`], but allows specifying a custom [`MessageRouter`]
/// instead of using the [`MessageRouter`] provided to the [`ChannelManager`] at construction.
///
Expand Down Expand Up @@ -13194,6 +13264,13 @@ where

let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);

#[cfg(not(feature = "std"))]
let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64);
#[cfg(feature = "std")]
let created_at = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH");

let builder = self.flow.create_invoice_builder_from_refund(
&self.router,
entropy,
Expand All @@ -13203,6 +13280,7 @@ where
self.create_inbound_payment(Some(amount_msats), relative_expiry, None)
.map_err(|()| Bolt12SemanticError::InvalidAmount)
},
created_at,
)?;

let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?;
Expand Down Expand Up @@ -15353,7 +15431,7 @@ where
None => return None,
};

let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) {
let verified_invoice_request = match self.flow.verify_invoice_request(invoice_request, context) {
Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request,
Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => {
self.pending_events.lock().unwrap().push_back((Event::StaticInvoiceRequested {
Expand All @@ -15364,6 +15442,90 @@ where
},
Err(_) => return None,
};
let invoice_request = verified_invoice_request.inner();

#[cfg(not(feature = "std"))]
let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64);
#[cfg(feature = "std")]
let created_at = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH");

// Recurrence checks
let recurrence_basetime = if let Some(recurrence_fields) = invoice_request.recurrence_fields() {
let payer_id = invoice_request.payer_signing_pubkey();
let mut sessions = self.active_recurrence_sessions.lock().unwrap();

// We first categorise the invoice request based on it's type.
let recurrence_counter = invoice_request.recurrence_counter();
let recurrence_cancel = invoice_request.recurrence_cancel();
let existing_session = sessions.get(&payer_id);

match (existing_session, recurrence_counter, recurrence_cancel) {
// This represents case where the payer, didn't support recurrence
// but we set recurrence optional so we allow payer to pay one-off
(None, None, None) => { None },
// It's the first invoice request in recurrence series
(None, Some(0), None) => {
let recurrence_basetime = recurrence_fields
.recurrence_base
.map(|base| base.basetime)
.unwrap_or(created_at.as_secs());

// Next we prepare recurrence_data to be stored in our recurrence session
let recurrence_data = RecurrenceData {
invoice_request_start: invoice_request.recurrence_start(),
next_payable_counter: 0,
recurrence_basetime,
};
// Now we store it in our active_recurrence_session
sessions.insert(payer_id, recurrence_data);

Some(recurrence_basetime)

},
// it's a successive invoice request in recurrence series
(Some(data), Some(counter), None) if counter > 0 => {
// We confirm all the data to ensure this is an expected successive invoice request
if data.invoice_request_start != invoice_request.recurrence_start()
|| data.next_payable_counter != counter
{
return None
}

// Next we ensure that the successive invoice_request is received between the period's paywindow
if let Some(window) = recurrence_fields.recurrence_paywindow {
let period_index = data.invoice_request_start.unwrap_or(0) + counter;

let period_start = data.recurrence_basetime
+ period_index as u64 * recurrence_fields.recurrence.period_length_secs().unwrap();

if created_at.as_secs() < period_start - window.seconds_before as u64
|| created_at.as_secs() >= period_start + window.seconds_after as u64
{
return None
}
Comment on lines +15500 to +15507
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential arithmetic underflow in paywindow validation.

The expression period_start - window.seconds_before as u64 can underflow if seconds_before exceeds period_start. This could occur for early periods with large seconds_before values, causing the check to behave unexpectedly.

Consider using saturating subtraction:

-								if created_at.as_secs() < period_start - window.seconds_before as u64
+								if created_at.as_secs() < period_start.saturating_sub(window.seconds_before as u64)
									|| created_at.as_secs() >= period_start + window.seconds_after as u64

Also verify that period_start + window.seconds_after cannot overflow (though this is less likely given practical values).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let period_start = data.recurrence_basetime
+ period_index as u64 * recurrence_fields.recurrence.period_length_secs().unwrap();
if created_at.as_secs() < period_start - window.seconds_before as u64
|| created_at.as_secs() >= period_start + window.seconds_after as u64
{
return None
}
let period_start = data.recurrence_basetime
period_index as u64 * recurrence_fields.recurrence.period_length_secs().unwrap();
if created_at.as_secs() < period_start.saturating_sub(window.seconds_before as u64)
|| created_at.as_secs() >= period_start + window.seconds_after as u64
{
return None
}
🤖 Prompt for AI Agents
In lightning/src/ln/channelmanager.rs around lines 15500 to 15507, the
subtraction period_start - window.seconds_before as u64 can underflow for early
periods and the addition period_start + window.seconds_after as u64 could
overflow; replace the subtraction with a saturating_sub (e.g.,
period_start.saturating_sub(window.seconds_before as u64)) and replace the
addition with either checked_add (and handle the None case conservatively) or
saturating_add to prevent overflow, ensuring all casts to u64 are correct and
handling the checked_add None by treating it as out-of-range (return None) or
using a sensible saturating bound.

}

Some(data.recurrence_basetime)
},
// it's a cancel recurrence invoice request
(Some(_data), Some(counter), Some(())) if counter > 0 => {
// Here we simply remove the data from our sessions
sessions.remove(&payer_id);

// And since cancellation invoice request are stub invoice request,
// we don't respond to this invoice request
return None
},
_ => {
debug_assert!(false, "Should be unreachable, as all the invalid cases are handled during parsing");
return None
}
}
} else {
None
};

let get_payment_info = |amount_msats, relative_expiry| {
self.create_inbound_payment(
Expand All @@ -15373,17 +15535,22 @@ where
).map_err(|_| Bolt12SemanticError::InvalidAmount)
};

let (result, context) = match invoice_request {
let (result, context) = match verified_invoice_request {
InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => {
let result = self.flow.create_invoice_builder_from_invoice_request_with_keys(
&self.router,
&request,
self.list_usable_channels(),
get_payment_info,
created_at
);

match result {
Ok((builder, context)) => {
Ok((mut builder, context)) => {
recurrence_basetime.map(|basetime|
builder.set_invoice_recurrence_basetime(basetime)
);

let res = builder
.build_and_sign(&self.secp_ctx)
.map_err(InvoiceError::from);
Expand All @@ -15404,10 +15571,14 @@ where
&request,
self.list_usable_channels(),
get_payment_info,
created_at
);

match result {
Ok((builder, context)) => {
Ok((mut builder, context)) => {
recurrence_basetime.map(|basetime|
builder.set_invoice_recurrence_basetime(basetime)
);
let res = builder
.build()
.map_err(InvoiceError::from)
Expand Down Expand Up @@ -17259,6 +17430,7 @@ where
let mut inbound_payment_id_secret = None;
let mut peer_storage_dir: Option<Vec<(PublicKey, Vec<u8>)>> = None;
let mut async_receive_offer_cache: AsyncReceiveOfferCache = AsyncReceiveOfferCache::new();
let mut active_recurrence_sessions = Some(new_hash_map());
read_tlv_fields!(reader, {
(1, pending_outbound_payments_no_retry, option),
(2, pending_intercepted_htlcs, option),
Expand All @@ -17277,6 +17449,7 @@ where
(17, in_flight_monitor_updates, option),
(19, peer_storage_dir, optional_vec),
(21, async_receive_offer_cache, (default_value, async_receive_offer_cache)),
(23, active_recurrence_sessions, option),
});
let mut decode_update_add_htlcs = decode_update_add_htlcs.unwrap_or_else(|| new_hash_map());
let peer_storage_dir: Vec<(PublicKey, Vec<u8>)> = peer_storage_dir.unwrap_or_else(Vec::new);
Expand Down Expand Up @@ -18171,6 +18344,8 @@ where
router: args.router,
flow,

active_recurrence_sessions: Mutex::new(active_recurrence_sessions.unwrap()),

best_block: RwLock::new(best_block),

inbound_payment_key: expanded_inbound_key,
Expand Down
Loading
Loading