Skip to content

Design Discussion: Recurrence Support for BOLT12 Offers #4476

Open

Description

@shaavan

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:

  1. When the payment reaches the PaymentClaimable stage.
  2. When the payment is fully processed and PaymentClaimed is 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:

  1. Store the signing key directly as part of the recurrence session state.
  2. Derive the signing key deterministically, for example using a combination of RecurrenceId and 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_basetime may 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

  1. shaavan commented on Mar 11, 2026

    @shaavan
    MemberAuthor
  2. tnull commented on Mar 18, 2026

    @tnull
    Contributor

    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.

    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 ChannelManager and rather reduce complexity / move towards making it leaner over time where possible.

    Furthermore, IIUC, the RecurrenceManager would need to trigger persistence synchronously. Given that we by now also have the async KVStore (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 how RecurrenceManager could 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.

  3. TheBlueMatt commented on Mar 19, 2026

    @TheBlueMatt
    Collaborator

    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 recurring Offer like we do any other Offer (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 in lightning IMO, aside from exposing utilities on the Offer object to indicate that it is recurring and to calculate the next payment time from a given payment time.

  4. jkczyz commented on Mar 19, 2026

    @jkczyz
    Contributor

    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.

    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 InvoiceRequestReceived event, or similar, or using OffersMessageFlow directly in LDK Node.

    Are there any alternatives you're thinking of?

    For outbound recurring offers, I agree with @tnull it should basically just live downstream of lightning. We should support paying a recurring Offer like we do any other Offer (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 in lightning IMO, aside from exposing utilities on the Offer object to indicate that it is recurring and to calculate the next payment time from a given payment time.

    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.

  5. shaavan commented on Mar 24, 2026

    @shaavan
    MemberAuthor

    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:

    • Reframe recurrence session ownership as primarily an LDK Node concern, and evaluate how much of the PoC’s inbound/outbound session tracking can instead be represented using existing or slightly extended LDK Node BOLT 12 payment tracking.
    • Reduce the LDK-side recurrence work to the minimum protocol-facing/API surface needed to support downstream handling. In particular, this likely includes exposing recurrence capability/details on offers and allowing invoice requests to be created with an explicit payer signing key so downstream code can maintain a consistent payer signing pubkey across a recurrence session.
    • Re-evaluate which recurrence checks truly need to happen synchronously during invoice or invoice request handling. This seems like the main remaining question. If all recurrence-specific validation can be deferred downstream, then we may not need any new LDK ↔ LDK Node coordination point. If not, then we can look at the narrowest possible manual handling hook, for example an 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.

  6. vincenzopalazzo commented on Mar 24, 2026

    @vincenzopalazzo
    Member

    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.

    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.

  7. TheBlueMatt commented on Mar 24, 2026

    @TheBlueMatt
    Collaborator

    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.

    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.

  8. shaavan commented on Mar 28, 2026

    @shaavan
    MemberAuthor

    @vincenzopalazzo

    Is it an option to consider "Deterministic derivation" Something like: payer_id = derive_key(expanded_key, offer_id, recurrence_session_id)? ... This should avoid storing raw signing keys and keep the existing derivation pattern.

    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

    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.

    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.

  9. jkczyz commented on Mar 31, 2026

    @jkczyz
    Contributor

    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.

    I was thinking from the payee's perspective in LDK Node.

    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.

    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 different payer_ids. As the payee, how would we treat these?

  10. TheBlueMatt commented on Apr 7, 2026

    @TheBlueMatt
    Collaborator

    Is it an option to consider "Deterministic derivation" Something like: payer_id = derive_key(expanded_key, offer_id, recurrence_session_id)?

    If we're gonna have a recurrence_session_id that we have to store, what's the point of this? I Mean seems fine, but we have to store anyway...

    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 different payer_ids. As the payee, how would we treat these?

    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_id restriction 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 the payer_id (even if we did issue an invoice).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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