Skip to content

BOLT 12: re-add recurrence support. - #1240

Open
rustyrussell wants to merge 12 commits into
lightning:masterfrom
rustyrussell:guilt/offers-recurrence
Open

BOLT 12: re-add recurrence support.#1240
rustyrussell wants to merge 12 commits into
lightning:masterfrom
rustyrussell:guilt/offers-recurrence

Conversation

@rustyrussell

Copy link
Copy Markdown
Collaborator

This is taken from the old draft, and updated with modern field numbers and names.

I've made some minor changes:

  1. The proportional_amount field comes before seconds_before which seems more logical to me.
  2. The offer_absolute_expiry is clarified to only apply to the initial invoice request: once you're recurring the other fields take over.

The complexities mainly come from two sources:

  1. A requirement to be precise about when recurrence happens. This is because we want a push system, not a pull, since only the former allows for real authorization by the user, and there are many different reasonable ways to do recurrence.
  2. This is the first wallet feature which isn't just "fire and forget", but requires them to keep state and do unprompted actions.

One proposal I would like to add is a courtesy onion message when a user cancels a periodic payment. This is generally useful to tell the difference between a deliberate cancellation and a user-related failure.

This is taken from the old draft, and updated with modern field numbers and names.

I've made some minor changes:
1. The `proportional_amount` field comes before `seconds_before` which seems more logical to me.
2. The `offer_absolute_expiry` is clarified to only apply to the *initial* invoice request: once you're recurring the other fields take over.

The complexities mainly come from two sources:

1. A requirement to be precise about when recurrence happens.  This is because we want a push system, not a pull, since only the former allows for real authorization by the user, and there are many different reasonable ways to do recurrence.
2. This is the first wallet feature which isn't just "fire and forget", but requires them to keep state and do unprompted actions.

One proposal I would like to add is a courtesy onion message when a user cancels a periodic payment.  This is generally useful to tell the difference between a deliberate cancellation and a user-related failure.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md
Comment on lines +297 to +299
- add the offset days to get the day of the period start.
- if the day is not within the month, use the last day within the month.
- add the offset seconds to get the period start in seconds.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If the day is not within the month, we should use the last day of the month and the last second of that day.

We want a one month period starting on January 30th at 2AM to end before a one month period starting on January 31th at 1AM as it starts almost a full day later. However with your proposal, the former would end an hour after the latter.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

If you're having monthly recurrences, you don't care about +/- a day, BUT we have to define it somehow. I think you'll find this easier to implement as written, so I think that's better.

Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md Outdated
1. Remove start_any_period.  If you want them not to start in a random
   period (only appliciable if you set `offer_recurrence_base`) then
   use offer_absolute_expiry.
2. Remove years, use months.
3. Clarify leap seconds.
4. Put `proportional_amount` inside `offer_recurrence_base` where it
   belongs, not inside paywindow.
   
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Only offer_recurrence_base is compulsory, really.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
@rustyrussell

Copy link
Copy Markdown
Collaborator Author

OK, updated. I will need to rework the test vectors, however!

This allows the writer of the offer to decide how
pre-recurrence-supporting wallets should behave.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
@rustyrussell
rustyrussell force-pushed the guilt/offers-recurrence branch from 0ddbe23 to 147e86c Compare April 23, 2025 04:18

@vincenzopalazzo vincenzopalazzo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM overall, I want to take another look later in the month, but probably in this PR we can also remove the FIXME N 7 in the https://github.com/lightning/bolts/blob/master/12-offer-encoding.md#fixme-possible-future-extensions

@GBKS

GBKS commented Apr 29, 2025

Copy link
Copy Markdown

One proposal I would like to add is a courtesy onion message when a user cancels a periodic payment. This is generally useful to tell the difference between a deliberate cancellation and a user-related failure.

Subscriptions likely require more communication. From the initial sharing of the offer, to price changes, to notifying a user about a missed payment, providing a receipt, etc. Maybe best to leave this type of communication up to the participants to handle in their medium of choice?

@sbddesign

Copy link
Copy Markdown

Subscriptions likely require more communication. From the initial sharing of the offer, to price changes, to notifying a user about a missed payment, providing a receipt, etc. Maybe best to leave this type of communication up to the participants to handle in their medium of choice?

@GBKS I agree that from a UX perspective there is always going to be more communication required between the subscriber and the recipient. However, if there is a protocol-native way to create a subscription, I think there also needs to be a protocol-native way to cancel the subscription.

@GBKS

GBKS commented May 4, 2025

Copy link
Copy Markdown

However, if there is a protocol-native way to create a subscription, I think there also needs to be a protocol-native way to cancel the subscription.

Maybe. You're creating a subscription proposal, but it's not active until the payee accepts it and acts on it. It's more like a subscription request, like we have payment requests. So if you want a "subscription cancelled" message, you may also want a "subscription accepted" message. But TBH, I don't have a super strong opinion. Just doesn't seem 100% right to exclusively have the cancel message.

@rustyrussell

Copy link
Copy Markdown
Collaborator Author

Note: @TheBlueMatt pointed out that the correct behavior is to send the courtesy "cancel" message in place of the next request. Slightly more complex to implement (your wallet now needs to remember cancelled subscriptions), but it's a nice balance between letting the vendor know, and not letting them cut you off early. Apparently Apple Pay does something similar, and I can see the logic.

From an implementation POV, this may simply look like a stub invoice_request?

@rustyrussell

Copy link
Copy Markdown
Collaborator Author

One proposal I would like to add is a courtesy onion message when a user cancels a periodic payment. This is generally useful to tell the difference between a deliberate cancellation and a user-related failure.

Subscriptions likely require more communication. From the initial sharing of the offer, to price changes, to notifying a user about a missed payment, providing a receipt, etc. Maybe best to leave this type of communication up to the participants to handle in their medium of choice?

That, however, has proven to be a centralized disaster, and severely limits interoperation. You cannot automate this unspecified stuff...

Eventually we will probably want offer replacement (where we reject an invoice_request and optionally refers to an updated offer), but one step at a time!

Thanks https://github.com/a-mpch!

Also replace some errant tabs.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
This is a best-effort message, but helps differentiate a deliberate
cancellation by a user from wallet issues.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
@rustyrussell

Copy link
Copy Markdown
Collaborator Author

Added the "cancel" flag. You basically send an invreq like normal, but it says "cancel" instead of "send me the next invoice".

Also added the requirement to fetch the next invoice explicitly to the requirements for recurring payments!

Recurrence has the same fields in offer, invoice_request and invoice, and bip_353_name
has the same fields in invoice_request and invoice.

This is slightly clearer, and it makes code generation cleaner (since
they are clearly the same type, they are easier to use).

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Feedback from https://github.com/shaavan
1. rename max_period to max_period_index for a little more clarity.
2. clarify `offer_absolute_expiry` example.

@shaavan shaavan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for all the clarifications, Rusty and Thomas!

Comment thread 12-offer-encoding.md
Comment thread 12-offer-encoding.md
Comment thread 12-offer-encoding.md Outdated
Comment thread 12-offer-encoding.md
1. Remove reference to years (it's now seconds, days or months).
2. Make 0 illegal.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
@rustyrussell
rustyrussell force-pushed the guilt/offers-recurrence branch from cb79233 to 941b20d Compare May 28, 2026 10:14
Comment thread 12-offer-encoding.md
- MUST set `invreq_recurrence_counter` `counter` to 0.
- MUST NOT set `invreq_recurrence_cancel`.
- for any successive requests:
- MUST use the same `invreq_payer_id` as the initial request.

@shaavan shaavan Jun 15, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why does the payer need to maintain a stable invreq_payer_id across recurrence invoice requests? This appears to introduce public linkability for the payer across the lifetime of a recurrence session.

If the goal is to allow the payee to associate invoice requests with a particular recurrence session, could the recently added invoice_recurrence_prev_state and invoice_recurrence_next_state fields serve that purpose instead, avoiding the need for a stable payer identifier?

Comment thread 12-offer-encoding.md
`payment_preimage` that will be given in return for payment.
- if `offer_issuer_id` is present:
- MUST set `invoice_node_id` to the `offer_issuer_id`
- otherwise, if `offer_paths` is present:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Recurrence sessions may be very long-lived, potentially lasting months or years.

When offer_paths is present, subsequent recurrence invoice requests are required to use the same blinded paths from the original offer. This appears to require those paths to remain usable for the entire lifetime of the recurrence session, potentially long after the original offer itself has expired.

If those paths become invalid over time, how is the payer expected to continue the recurrence session?

More generally, should long-lived recurrence sessions depend on communication paths established at offer creation time, or is some mechanism for path refresh expected?

Comment thread 12-offer-encoding.md
1. A `time_unit` defining 0 (seconds), 1 (days), 2 (months).
2. A `period`, defining how often (in `time_unit`) it has to be paid.
3. An optional `offer_recurrence_limit` of total payments to be paid.
4. An optional `offer_recurrence_base`:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The current design allows a recurrence to be anchored either via offer_recurrence_base in the offer or via invoice_recurrence_basetime in the first invoice.

What advantages does allowing recurrence anchoring to originate from either the offer or the first invoice provide compared to requiring period #0 to always be defined at offer creation time and making the offer the sole source of truth for recurrence anchoring?

Additional Thoughts

I can see the value of payer-driven recurrence basetimes. However, I am curious whether that flexibility could be moved to the application layer while keeping recurrence anchoring entirely offer-defined.

For example:

  • A merchant could support generating recurrence-on-demand offers, with the basetime chosen when the payer decides to begin the recurrence.
  • For a long-lived static offer, a payer could begin participating after several recurrence periods have already elapsed, with the recurrence offset indicating the period at which participation began.

Would an approach like this preserve the same flexibility while keeping a single source of truth for recurrence anchoring?

Comment thread 12-offer-encoding.md

Note: you can create an recurring offer with a fixed start which can only be used from the first period, using `offer_absolute_expiry`. For example, my weekly book club starts January 1st 2026, for $5 a week: by setting `offer_absolute_expiry` to January 8th 2026, I ensure nobody can join after the first week. Now, if only I could require them to actually read this week's book!

For wallets which don't (yet) support recurrence, the basic recurrence field comes in two variants: `offer_recurrence_compulsory` if they should not attempt a payment, and `offer_recurrence_optional` if it still makes sense for them to attempt a single payment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The current design distinguishes between recurrence being optional or compulsory at the protocol level.

What advantages are gained by encoding this distinction in the recurrence specification itself?

Could recurrence instead be treated as a protocol extension where recurrence is always optional at the spec level, with applications advertising and deciding whether one-time payments are accepted or rejected?

Comment thread 12-offer-encoding.md
* which repeat from whenever you made the first payment

Thus, each offer containing a recurring payment has:
1. A `time_unit` defining 0 (seconds), 1 (days), 2 (months).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

From previous discussion: the smallest 'reasonable' unit here is likely hours, not seconds (for example, if mobile wallets need to wake up).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes I think we explicitly want this to be larger, otherwise people might attempt to use this for streaming payments which seems incorrect / a misuse

@martinsaposnic

Copy link
Copy Markdown

as a merchant/payee there's no way to clearly signal that a recurrence is terminated. only tool it has is rejecting the next invoice_request with a generic invoice_error. the payer's wallet can't tell "subscription terminated" from a transient failure, so it has to guess whether to retry next period or drop its session state

merchants discontinue products, change prices, and drop customers, so this will be hit constantly

@vincenzopalazzo

Copy link
Copy Markdown
Contributor

merchants discontinue products, change prices, and drop customers, so this will be hit constantly

Wondering if we can just leverage the error message when rejecting the invoice request? Make a special one to say: Wait, baby, this is the end of our relationship!

Comment thread 12-offer-encoding.md
* which repeat from whenever you made the first payment

Thus, each offer containing a recurring payment has:
1. A `time_unit` defining 0 (seconds), 1 (days), 2 (months).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes I think we explicitly want this to be larger, otherwise people might attempt to use this for streaming payments which seems incorrect / a misuse

Comment thread 12-offer-encoding.md
- if `offer_currency` is not the `invreq_chain` currency, convert to the
`invreq_chain` currency.
- if `invreq_quantity` is present, multiply by `invreq_quantity`.`quantity`.
- if `offer_recurrence_base` is present and `proportional_amount` is 1:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it's not defined for what to do if offer_recurrence_base is not present when proportional_amount is. I assume this is invalid?

Comment thread 12-offer-encoding.md
- MUST set `seconds_before` to the maximum number of seconds prior to a period for which it will accept payment or invoice_request for that period.
- MUST set `seconds_after` to the maximum number of seconds into to a period for which it will accept payment or invoice_request for that period.
- MAY NOT enforce this for the initial period for offers without `offer_recurrence_base`
- SHOULD NOT set `seconds_after` to greater than the maximum number of seconds in a period.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Related to previous comments here: should seconds_before also be clamped to the size of a period? Such that a payer can only pay in advance for a maximum of one recurrence.

Without clamping, a payer could pre-pay for a full recurrence (or a significant portion of it) at once, which may be undesirable behavior from the perspective of the payee. From what I've seen of subscriptions in the wild, usually the price of a recurrence is tied to the period, and making multiple pre-payments is disallowed.

Concrete example:

A monthly subscription of 10 USD, for 1 year. seconds_before = 1 year, seconds_after = 1 month.

IIUC, with the spec as written the payer can pay for all 12 periods upfront, effectively 'locking in' the current exchange rate. I would expect the payee here to want to provide a different offer for a 12 month prepayment, likely with a lower price (100 USD for 1 year of service, or 100 USD per year, etc.)

tl;dr not clamping BOTH seconds_before and seconds_after seems like leaving a potential footgun around. Happy to hear counterarguments though.

Comment thread 12-offer-encoding.md
Comment on lines +253 to +256
1. subtype: `recurrence_base`
2. data:
* [`byte`:`proportional_amount`]
* [`tu64`:`basetime`]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was wondering whether basetime and proportional_amount should be separate subtypes?

For example, a merchant may want to let the payer choose when to start a recurrence (i.e., by not setting basetime) while also allowing them to pay a proportional amount beginning with the first successive period.

Also, I think @jharveyb raised an interesting point (offline) about proportional amounts. IIUC, the current interpretation effectively acts as a discount. For example, if you pay 7 days late in a month-long period, you only pay for the remaining ~75% of the period.

In practice, though, merchants often don't terminate service immediately when a payment is late. For example, Spotify gives users a grace period before suspending service.

Jonathan's suggestion, if I understood it correctly, was that proportional amounts could instead be interpreted as a late-payment penalty. In other words, the payer would owe more if they paid after the due date, rather than less.

That might also help address another issue. Today, once the payment window is missed, the recurrence becomes void. If proportional amounts represented a penalty instead, it might allow payments even after the payment window, with the additional amount serving as the late-payment penalty.

I'd love to hear @jharveyb's thoughts on whether that's what he had in mind. Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.