decode: fix decode on recurring offers.
What changed, and why it matters
This commit fixes a bug in Core Lightning's `decode` command for recurring payment offers. A previous code change split one offer field into two variants (compulsory and optional), but the `decode` output was not updated to match the new schema. The fix makes `decode` report a single `offer_recurrence` block with a new boolean flag indicating whether recurrence is compulsory. It is a correctness/API bug, not a direct funds-loss vulnerability, because it affects an experimental feature and would mainly cause decoding errors or misleading output rather than unauthorized payments.
Treat as a routine bug fix. Users relying on experimental BOLT12 recurring offers and the `decode` RPC should upgrade to a release containing this commit. No emergency response is warranted; review the schema change if you have tooling that parses `decode` output for `offer_recurrence`.
Security signals we found
API/schema mismatch in an experimental feature (BOLT12 offers recurrence)
Fixes a decode failure/regression introduced in v25.12
No evidence of memory corruption, authentication bypass, or funds theft
Change is defensive: aligns emitted JSON with documented schema
Evidence from the diff
In v25.12 the offers code replaced offer_recurrence with offer_recurrence_compulsory and offer_recurrence_optional, but plugins/offers.c still emitted JSON under the old/variant field names and the decode JSON schema still expected offer_recurrence. This mismatch caused decode to fail validation for offers with recurrence. The patch changes json_add_recurrence() to always emit offer_recurrence and adds a compulsory_field boolean, then updates .msggen.json, contrib/msggen/msggen/schema.json, doc/schemas/decode.json, contrib/pyln-testing/pyln/testing/grpc2py.py, and a test in tests/test_pay.py to match. The test now also decodes the offer and an invoice request to verify the output.
Changed components
plugins/offers.cdoc/schemas/decode.jsoncontrib/msggen/msggen/schema.json.msggen.jsoncontrib/pyln-testing/pyln/testing/grpc2py.pytests/test_pay.pyInspect captured patch +67 / −10
diff --git a/.msggen.json b/.msggen.json
index 26f71567..f65ed797 100644
--- a/.msggen.json
+++ b/.msggen.json
@@ -6818,6 +6818,10 @@
"added": "v23.05",
"deprecated": null
},
+ "Decode.offer_recurrence.compulsory_field": {
+ "added": "v26.06",
+ "deprecated": null
+ },
"Decode.offer_recurrence.limit": {
"added": "v23.05",
"deprecated": null
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 334c5346..b1f2e5cf 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -8102,7 +8102,8 @@
],
"required": [
"period",
- "time_unit"
+ "time_unit",
+ "compulsory_field"
],
"additionalProperties": false,
"properties": {
@@ -8136,6 +8137,13 @@
"Maximum period number for recurrence."
]
},
+ "compulsory_field": {
+ "added": "v26.06",
+ "type": "boolean",
+ "description": [
+ "True if this is the `offer_recurrence_compulsory` field (which means readers which don't understand recurrence cannot use it), false if this is the `offer_recurrence_optional` field."
+ ]
+ },
"paywindow": {
"type": "object",
"description": [
@@ -8554,6 +8562,13 @@
"Maximum period number for recurrence."
]
},
+ "compulsory_field": {
+ "added": "v26.06",
+ "type": "boolean",
+ "description": [
+ "True if this is the `offer_recurrence_compulsory` field (which means readers which don't understand recurrence cannot use it), false if this is the `offer_recurrence_optional` field."
+ ]
+ },
"paywindow": {
"type": "object",
"description": [
@@ -9208,6 +9223,13 @@
"Maximum period number for recurrence."
]
},
+ "compulsory_field": {
+ "added": "v26.06",
+ "type": "boolean",
+ "description": [
+ "True if this is the `offer_recurrence_compulsory` field (which means readers which don't understand recurrence cannot use it), false if this is the `offer_recurrence_optional` field."
+ ]
+ },
"paywindow": {
"type": "object",
"description": [
diff --git a/contrib/pyln-testing/pyln/testing/grpc2py.py b/contrib/pyln-testing/pyln/testing/grpc2py.py
index 9b9dc8c5..a1dcfc44 100644
--- a/contrib/pyln-testing/pyln/testing/grpc2py.py
+++ b/contrib/pyln-testing/pyln/testing/grpc2py.py
@@ -1223,6 +1223,7 @@ def decode_offer_recurrence_paywindow2py(m):
def decode_offer_recurrence2py(m):
return remove_default({
"basetime": m.basetime, # PrimitiveField in generate_composite
+ "compulsory_field": m.compulsory_field, # PrimitiveField in generate_composite
"limit": m.limit, # PrimitiveField in generate_composite
"period": m.period, # PrimitiveField in generate_composite
"time_unit": m.time_unit, # PrimitiveField in generate_composite
diff --git a/doc/schemas/decode.json b/doc/schemas/decode.json
index 4e642d93..21851a8f 100644
--- a/doc/schemas/decode.json
+++ b/doc/schemas/decode.json
@@ -279,7 +279,8 @@
],
"required": [
"period",
- "time_unit"
+ "time_unit",
+ "compulsory_field"
],
"additionalProperties": false,
"properties": {
@@ -313,6 +314,13 @@
"Maximum period number for recurrence."
]
},
+ "compulsory_field": {
+ "added": "v26.06",
+ "type": "boolean",
+ "description": [
+ "True if this is the `offer_recurrence_compulsory` field (which means readers which don't understand recurrence cannot use it), false if this is the `offer_recurrence_optional` field."
+ ]
+ },
"paywindow": {
"type": "object",
"description": [
@@ -731,6 +739,13 @@
"Maximum period number for recurrence."
]
},
+ "compulsory_field": {
+ "added": "v26.06",
+ "type": "boolean",
+ "description": [
+ "True if this is the `offer_recurrence_compulsory` field (which means readers which don't understand recurrence cannot use it), false if this is the `offer_recurrence_optional` field."
+ ]
+ },
"paywindow": {
"type": "object",
"description": [
@@ -1385,6 +1400,13 @@
"Maximum period number for recurrence."
]
},
+ "compulsory_field": {
+ "added": "v26.06",
+ "type": "boolean",
+ "description": [
+ "True if this is the `offer_recurrence_compulsory` field (which means readers which don't understand recurrence cannot use it), false if this is the `offer_recurrence_optional` field."
+ ]
+ },
"paywindow": {
"type": "object",
"description": [
diff --git a/plugins/offers.c b/plugins/offers.c
index 3aa5d0c5..72624e94 100644
--- a/plugins/offers.c
+++ b/plugins/offers.c
@@ -856,10 +856,12 @@ static void json_add_recurrence(struct json_stream *js,
const struct recurrence *offer_recurrence,
const struct recurrence_paywindow *offer_recurrence_paywindow,
const u32 *offer_recurrence_limit,
- const struct recurrence_base *offer_recurrence_base)
+ const struct recurrence_base *offer_recurrence_base,
+ bool compulsory)
{
const char *name;
json_object_start(js, fieldname);
+ json_add_bool(js, "compulsory_field", compulsory);
json_add_num(js, "time_unit", offer_recurrence->time_unit);
name = recurrence_time_unit_name(offer_recurrence->time_unit);
if (name)
@@ -953,17 +955,19 @@ static bool json_add_offer_fields(struct command *cmd,
json_add_u64(js, "offer_quantity_max", *offer_quantity_max);
if (offer_recurrence_compulsory)
- json_add_recurrence(js, "offer_recurrence_compulsory",
+ json_add_recurrence(js, "offer_recurrence",
offer_recurrence_compulsory,
offer_recurrence_paywindow,
offer_recurrence_limit,
- offer_recurrence_base);
+ offer_recurrence_base,
+ true);
if (offer_recurrence_optional)
- json_add_recurrence(js, "offer_recurrence_optional",
+ json_add_recurrence(js, "offer_recurrence",
offer_recurrence_optional,
offer_recurrence_paywindow,
offer_recurrence_limit,
- offer_recurrence_base);
+ offer_recurrence_base,
+ false);
if (offer_issuer_id)
json_add_pubkey(js, "offer_issuer_id", offer_issuer_id);
diff --git a/tests/test_pay.py b/tests/test_pay.py
index 6f87d8ad..79ec022e 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -7055,6 +7055,8 @@ def test_cancel_recurrence(node_factory):
offer = l2.rpc.offer(amount='1msat',
description='test_cancel_recurrence',
recurrence='1minutes')
+ # Check it decodes ok!
+ l1.rpc.decode(offer['bolt12'])
# We cannot cancel if we never got the first one.
with pytest.raises(RpcError, match="recurrence_counter: Must be non-zero"):
@@ -7068,7 +7070,9 @@ def test_cancel_recurrence(node_factory):
recurrence_counter=0,
recurrence_label='test_cancel_recurrence')
l1.rpc.pay(ret['invoice'], label='test_cancel_recurrence')
- l1.rpc.wait_for_log('invoice_request: ')
+ m = re.search(r'invoice_request: "([a-z0-9]*)"', l1.daemon.wait_for_log('plugin-offers: invoice_request:'))
+ decoded = l1.rpc.decode(m.group(1))
+ assert 'invreq_recurrence_cancel' not in decoded
# Cancel counter must be correct!
with pytest.raises(RpcError, match=r"previous invoice has not been paid \(last was 0\)"):
@@ -7080,9 +7084,9 @@ def test_cancel_recurrence(node_factory):
recurrence_label='test_cancel_recurrence')
# Get invoice request: it will have invreq_recurrence_cancel
- m = re.search(r'invoice_request: "([a-z0-9]*)"', l1.daemon.is_in_log('invoice_request:'))
+ m = re.search(r'invoice_request: "([a-z0-9]*)"', l1.daemon.wait_for_log('plugin-offers: invoice_request:'))
decoded = l1.rpc.decode(m.group(1))
- assert decoded == []
+ assert decoded['invreq_recurrence_cancel'] is True
# Now we cannot fetch second one!
with pytest.raises(RpcError, match=r"invoice expired \(cancelled\?\)"):
Why this scored 23/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.