plugins/fetchinvoice: allow use of expired offers *for recurrence*.
What changed, and why it matters
This change updates how Core Lightning handles recurring payment offers after they expire. Previously, an expired offer could not be used at all. Now, the first invoice request must still happen before expiration, but follow-up recurring payments can continue even after the offer's expiration date. This is an intentional spec change, not a security bug, but it slightly loosens a time-based restriction.
No immediate action required. Review whether the relaxed expiry semantics align with your operational security expectations for recurring offers. Monitor the BOLT-recurrence spec discussion for further changes.
Security signals we found
Time-based access control relaxed for recurring invoice requests
Behavior change tied to BOLT-recurrence specification update
No input validation, memory safety, or cryptographic changes observed
Evidence from the diff
The commit modifies two plugins to implement BOLT-recurrence #12: offer_absolute_expiry now only blocks initial invoice requests (when recurrence_counter is absent or 0). In fetchinvoice.c and offers_invreq_hook.c, the expiry check is gated by (!recurrence_counter || *recurrence_counter == 0). A test confirms that a counter=1 fetchinvoice succeeds after the offer has expired. This is a protocol behavior change, not a vulnerability fix.
Changed components
plugins/fetchinvoice.cplugins/offers_invreq_hook.ctests/test_pay.pyInspect captured patch +36 / −2
diff --git a/plugins/fetchinvoice.c b/plugins/fetchinvoice.c
index b70c9f4a..6343bd3e 100644
--- a/plugins/fetchinvoice.c
+++ b/plugins/fetchinvoice.c
@@ -863,9 +863,16 @@ struct command_result *json_fetchinvoice(struct command *cmd,
* - if the current time is after `offer_absolute_expiry`:
* - MUST NOT respond to the offer.
*/
+ /* BOLT-recurrence #12:
+ * - if the current time is after `offer_absolute_expiry`:
+ * - MUST NOT make an initial response to the offer
+ * (i.e. continuing an existing offer with recurrence is ok)
+ */
if (sent->offer->offer_absolute_expiry
- && time_now().ts.tv_sec > *sent->offer->offer_absolute_expiry)
+ && time_now().ts.tv_sec > *sent->offer->offer_absolute_expiry
+ && (!recurrence_counter || *recurrence_counter == 0)) {
return command_fail(cmd, OFFER_EXPIRED, "Offer expired");
+ }
/* BOLT #12:
* The writer:
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index 0c0b8ee4..eeb448d5 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -848,9 +848,16 @@ static struct command_result *listoffers_done(struct command *cmd,
json_tok_full(buf, offertok));
}
+ /* BOLT-recurrence #12:
+ * - if `offer_absolute_expiry` is present, and
+ * `invreq_recurrence_counter` is either not present or equal to 0:
+ * - MUST reject the invoice request if the current time is after
+ * `offer_absolute_expiry`.
+ */
if (ir->invreq->offer_absolute_expiry
+ && (!ir->invreq->invreq_recurrence_counter
+ || *ir->invreq->invreq_recurrence_counter == 0)
&& time_now().ts.tv_sec >= *ir->invreq->offer_absolute_expiry) {
- /* FIXME: do deloffer to disable it */
return fail_invreq(cmd, ir, "Offer expired");
}
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 4703f876..cfb3c23d 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -4725,6 +4725,26 @@ def test_fetchinvoice_recurrence(node_factory, bitcoind):
'recurrence_label': 'test paywindow'})
+def test_recurrence_expired_offer(node_factory, bitcoind):
+ """We *can* use an expired offer for successive recurrences"""
+ l1, l2 = node_factory.line_graph(2)
+
+ offer = l2.rpc.offer(amount='1msat',
+ description='paywindow test',
+ recurrence='20seconds',
+ absolute_expiry=int(time.time()) + 15)
+ ret = l1.rpc.fetchinvoice(offer=offer['bolt12'],
+ recurrence_counter=0,
+ recurrence_label='test_recurrence_expired_offer')
+ l1.rpc.pay(ret['invoice'], label='test_recurrence_expired_offer')
+
+ time.sleep(16)
+ ret = l1.rpc.fetchinvoice(offer=offer['bolt12'],
+ recurrence_counter=1,
+ recurrence_label='test_recurrence_expired_offer')
+ l1.rpc.pay(ret['invoice'], label='test_recurrence_expired_offer')
+
+
def test_fetchinvoice_autoconnect(node_factory, bitcoind):
"""We should autoconnect if we need to, to route."""
Why this scored 32/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.