Repository navigation
Design Discussion: Recurrence Support for BOLT12 Offers #4476
Description
Introduction
I want to use this issue to discuss the main points of decision for implementing recurrence support.
Spec PR: lightning/bolts#1240
A draft PoC implementation can be found here: #4302
The branch is structured roughly as:
- introducing the wire fields
- implementing the payee flow
- implementing the payer flow
The PoC intentionally takes a few faster or less optimal shortcuts to demonstrate the recurrence mechanism end-to-end and validate the overall approach.
During implementation I identified several architectural decision points that would benefit from discussion before moving toward a more finalized design.
Recurrence State Ownership
The first major design question concerns where recurrence session state should live.
To manage recurring payments, both inbound and outbound flows require maintaining a small amount of session state. In the PoC implementation this is handled through:
However, LDK today is stateless for inbound payments. Introducing inbound recurrence tracking directly inside LDK would therefore represent a notable architectural shift.
To maintain uniformity between inbound and outbound handling, an alternative approach is to move recurrence state management entirely to the application layer (i.e., LDK node).
This raises the question of how LDK and LDK node should communicate when recurrence state needs to be accessed or updated.
Two possible approaches emerge:
1. Event-based management
LDK emits events whenever recurrence state needs to be accessed or updated.
LDK node captures these events and performs the corresponding state operations through the available public APIs.
2. Trait-based management
OffersMessageFlow (and therefore ChannelManager) is parameterized over a RecurrenceManager trait.
All recurrence state reads and writes occur through trait methods, while LDK node implements the trait and manages the internal state.
Current reasoning
The trait-based approach appears preferable for one key reason.
An event-driven approach introduces asynchronous-style handling into a path that ideally remains synchronous. Since creating invoice requests is essentially free for an attacker while event processing carries real cost, an event-based design could introduce DOS-adjacent attack vectors.
By contrast, a trait-based approach:
- keeps recurrence handling fully synchronous
- maintains a clear separation of concerns
- avoids event amplification risks
The main downside is slightly increased generic complexity in ChannelManager, but this appears manageable.
This represents the first core design question for recurrence: who owns recurrence session state, and how LDK should interact with it.
Additional Design Questions
Beyond state ownership, the PoC surfaced several smaller but still important design questions that would benefit from discussion before the recurrence implementation is finalized.
These questions primarily relate to lifecycle handling, key management, and automation behaviour in recurrence sessions.
1. Recurrence Cycle Completion
One design question is when a recurrence cycle should be considered completed.
Two natural points exist in the payment lifecycle:
- When the payment reaches the
PaymentClaimablestage. - When the payment is fully processed and
PaymentClaimedis generated.
Updating recurrence state at PaymentClaimable would allow earlier state transitions, but it introduces the risk of advancing the recurrence session before the payment is actually finalized.
Updating the state when PaymentClaimed is generated ensures the payment has successfully completed before advancing the recurrence counter.
The PoC currently updates recurrence state when transitioning from PaymentClaimable to PaymentClaimed.
2. Signing Key Management for Recurrence InvoiceRequests
For a given recurrence session, all invoice requests must use the same signing key.
This raises the question of how that key should be managed.
Two possible approaches exist:
- Store the signing key directly as part of the recurrence session state.
- Derive the signing key deterministically, for example using a combination of
RecurrenceIdand the node’s expanded key.
Storing the key simplifies the implementation but introduces additional key storage.
Deterministic derivation avoids storing additional secrets but requires careful key derivation logic.
3. Offer Storage for the Payer Flow
When generating successive invoice requests in the payer flow, the current implementation requires access to the entire Offer object.
This introduces an awkward lifecycle:
- The outbound recurrence session is partially initialized when the primary invoice request is created.
- The
recurrence_basetimemay only become known when the first invoice is received. - The session must therefore be updated after the first invoice arrives.
One question is whether subsequent invoice requests could be constructed using OfferContents alone.
If that were possible, it would avoid storing the full Offer and allow the recurrence session to be initialized fully only after the first invoice is received.
4. Triggering Subsequent InvoiceRequests
When a recurrence period becomes due, the PoC automatically creates and sends the next invoice request.
An alternative approach would be to emit an event and require the application to explicitly create and send the next invoice request.
Automatic triggering simplifies the subscription-like behaviour of recurrence sessions, while event-driven triggering would give applications more control over scheduling and payment policies.
5. Retry Behaviour
When an invoice request for the next recurrence cycle is sent, the PoC currently schedules retries by advancing next_trigger_time by a short interval (e.g., ~30 seconds) until the payment for the current period succeeds.
Questions here include:
- whether retries should be handled automatically
- what retry interval is appropriate
- whether retry behaviour should be configurable
I would appreciate feedback on the points above, especially around the state ownership model and the trait vs event approach. Any thoughts on the proposed directions or alternative designs would be very helpful before moving forward with a more refined implementation.
Activity
shaavan commentedon Mar 11, 2026
cc @jkczyz, @tnull, @TheBlueMatt
tnull commentedon Mar 18, 2026
I'm just starting to catch up with the Recurrence discussion, but my initial thought is that as much as possible of this should live in LDK Node as Recurrence seems more liken an application layer concern. We also generally want to move away from adding everything to
ChannelManagerand rather reduce complexity / move towards making it leaner over time where possible.Furthermore, IIUC, the
RecurrenceManagerwould need to trigger persistence synchronously. Given that we by now also have the asyncKVStore(which will be more and more the default going forward) it seems the trait approach might become immediately more complex as we would once again need to find a way howRecurrenceManagercould use either of the persistence pipelines (e.g., providing a separate async version, etc). The event-based approach seems more sane here, as it would leave it to the user how to exactly persist the data, though it might also trigger unnecessary repersistence of the event queue.One open question for me is if we see a way to move more of the actual recurrence handling logic to LDK Node in the first place, which might potentially avoid this LDK<>LDK Node communication question in the first place? Not sure about previous discussions though.
TheBlueMatt commentedon Mar 19, 2026
For inbound recuring offers, IMO we should do nothing special at all. LDK Node's existing BOLT 12 payment store should be able to handle this (or it should be tweaked slightly to handle it) - you should be able to just query for a given offer and see all the payments for it (including the most recent one) which should be enough to decide if a payment deadline was missed. We could consider providing notifications that a payment deadline was missed but not really sure its worth it.
For outbound recurring offers, I agree with @tnull it should basically just live downstream of
lightning. We should support paying a recurringOfferlike we do any otherOffer(which presumably we already do?) and...that's kinda it? Ultimately the downstream code is going to have to have tight integration for recurring offers because it has to bubble up all the way to the UI, so there's not really all that much we really should do inlightningIMO, aside from exposing utilities on theOfferobject to indicate that it is recurring and to calculate the next payment time from a given payment time.jkczyz commentedon Mar 19, 2026
FWIW, it would need to track payments from a given payer id for an offer since an offer could be re-used by different payers.
Also, we need to decide whether or not to respond with an invoice for a given request depending on whether the spec requirements are met. If this logic leaks into LDK Node, then we need to signal back upstream as to whether to respond with the invoice. This kinda brings us back to needing an
InvoiceRequestReceivedevent, or similar, or usingOffersMessageFlowdirectly in LDK Node.Are there any alternatives you're thinking of?
The spec requires that the invoice request use a consistent payer id, so at very least we'll need to support that in LDK as well.
shaavan commentedon Mar 24, 2026
Thanks all, this feedback is very helpful.
My current takeaway is that the right next step is to re-scope recurrence around a thinner LDK responsibility boundary.
I broadly agree with moving recurrence session tracking, scheduling, retries, and related lifecycle management into LDK Node rather than introducing a dedicated recurrence state layer inside LDK. That seems preferable architecturally and practically, since it avoids pushing more long-lived state into ChannelManager and avoids needing to force either a trait-based or event-based state access model between LDK and LDK Node.
So the next step I’m planning to take is:
InvoiceReceived/InvoiceRequestReceived-style event for recurrence-related flows, rather than introducing a broader recurrence manager trait abstraction in LDK.After that, I’ll follow up with a revised design sketch based on this thinner split, rather than continuing from the current PoC architecture directly.
If I’m overlooking anything or missing constraints here, I’d really appreciate any pointers as I start reshaping the implementation.
vincenzopalazzo commentedon Mar 24, 2026
Is it an option to consider "Deterministic derivation" Something like:
payer_id = derive_key(expanded_key, offer_id, recurrence_session_id)?Where recurrence_session_id could just be a nonce generated once when the user first subscribes to the offer, then stored as part of the minimal outbound session state. This should avoid storing raw signing keys and keep the existing derivation pattern. The nonce-per-session (rather than nonce-per-payment) is the key change.
TheBlueMatt commentedon Mar 24, 2026
That seems up to us, no? I mean we could just ignore this and users can use unique offers for each subscription? Even if the spec requires it we don't actually have to care, I think.
shaavan commentedon Mar 28, 2026
@vincenzopalazzo
This feels like a nice direction to explore. It seems like it could address the signing key management question I mentioned in the original issue, while still fitting into the existing derivation pattern. (Though we would still need the explicit key API for invoice request creation. See)
That said, I don’t think this fully removes the need to distinguish sessions at a higher level. A single offer can be reused by multiple payers, enabling multiple independent subscriptions, so we’d still need some notion of paired
(offer_id, session_id)tracking.@TheBlueMatt
Yeah, that might be a valid simplification.
At the same time, I can see flows where reusing the same offer feels pretty natural. For example, a static “support me monthly” offer where different users subscribe independently.
Requiring a fresh offer per subscription would work, but it does feel like it shifts some complexity outward.
Maybe we can explore making
(offer_id, session_id)-level tracking possible in LDK Node, and if it turns out to be too complex, fall back to a one-offer-per-session model.jkczyz commentedon Mar 31, 2026
I was thinking from the payee's perspective in LDK Node.
If such an offer is leaked somewhere (or a static one is desired as per @shaavan's comment), then we can still receive
InvoiceRequest's with differentpayer_ids. As the payee, how would we treat these?TheBlueMatt commentedon Apr 7, 2026
If we're gonna have a
recurrence_session_idthat we have to store, what's the point of this? I Mean seems fine, but we have to store anyway...Right, this came up in the spec call, but I was thinking basically we'd only bother to support (in LDK Node) the "one offer per subscriber" model - its the model merchants and general bitcoin folks are used to, so I don't see why we should rush to support the
payer_idrestriction if we dont ancitipate it'll be used much. In any case, a fork of LDK Node or a future change to LDK Node could support it if it becomes common, by simply bouncing payments based on thepayer_id(even if we did issue aninvoice).