plugins: `cancelrecurringinvoice` command.
What changed, and why it matters
This commit adds a brand-new experimental RPC command called cancelrecurringinvoice to Core Lightning. It lets a user politely tell a recurring BOLT 12 invoice issuer 'please stop sending me further invoices.' The change is purely additive: it introduces a new command, its documentation, and the code to build and send a cancellation message. Nothing in the commit removes protections, weakens authentication, or changes existing behavior of other commands.
No security action required. Treat as a normal feature review; consider validating input sanitization and BOLT 12 spec compliance during recurring-offers experimental hardening.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch implements json_cancelrecurringinvoice in plugins/fetchinvoice.c, registers it in plugins/offers.c, and adds JSON schema/man-page plumbing. The command constructs a tlv_invoice_request with the invreq_recurrence_cancel flag set, omits invreq_amount/invreq_quantity as allowed by the BOLT 12 recurrence extension, validates that the offer is recurring and that recurrence_counter is non-zero, derives the payer key from recurrence metadata, and sends the request via send_message without expecting a reply. sendonionmsg_done now short-circuits the normal invoice-response timeout when the sent request is a cancellation. No existing security checks are bypassed and no new trust assumptions are introduced.
Changed components
plugins/fetchinvoice.cplugins/fetchinvoice.hplugins/offers.cdoc/schemas/cancelrecurringinvoice.jsoncontrib/msggen/msggen/schema.jsondoc/Makefiledoc/index.rstInspect captured patch +338 / −1
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 7834628d..a0f42b9c 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -4828,6 +4828,87 @@
}
]
},
+ "cancelrecurringinvoice.json": {
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "added": "v25.09",
+ "rpc": "cancelrecurringinvoice",
+ "title": "Command for sending a cancel message for a recurring offer",
+ "description": [
+ "NOTE: Recurring offers are experimental, and may be changed in backwards-incompable ways.",
+ "",
+ "The **cancelrecurringinvoice** RPC command sends a cancellation message in place of an invoice_request. The BOLT 12 specification suggests sending this as a courtesy in place of the next invoice_request (as would be sent by fetchinvoice)."
+ ],
+ "request": {
+ "required": [
+ "offer",
+ "recurrence_counter",
+ "recurrence_label"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "offer": {
+ "type": "string",
+ "description": [
+ "Offer string (must be recurring) which we have been paying."
+ ]
+ },
+ "recurrence_counter": {
+ "type": "u64",
+ "description": [
+ "One later than the last-specified recurrence_counter for the last invoice."
+ ]
+ },
+ "recurrence_label": {
+ "type": "string",
+ "description": [
+ "This must be the same as prior fetchinvoice calls for the same recurrence, as it is used to link them together."
+ ]
+ },
+ "recurrence_start": {
+ "type": "number",
+ "description": [
+ "Indicates what period number to start at (usually 0). This will be the same as previous fetchinvoice calls."
+ ]
+ },
+ "payer_note": {
+ "type": "string",
+ "description": [
+ "To tell the issuer the reason for the cancellation."
+ ]
+ },
+ "bip353": {
+ "type": "string",
+ "description": [
+ "BIP353 string (optionally with \u20bf) indicating where we fetched the offer from"
+ ]
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "bolt12"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "bolt12": {
+ "type": "string",
+ "description": [
+ "The invoice_request we sent to the issuer."
+ ]
+ }
+ }
+ },
+ "author": [
+ "Rusty Russell <<rusty@rustcorp.com.au>> is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-fetchinvoice(7)"
+ ],
+ "resources": [
+ "Main web site: <https://github.com/ElementsProject/lightning>"
+ ]
+ },
"check.json": {
"$schema": "../rpc-schema-draft.json",
"type": "object",
diff --git a/doc/Makefile b/doc/Makefile
index 7c8acd36..b8498984 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -30,6 +30,7 @@ MARKDOWNPAGES := doc/addgossip.7 \
doc/bkpr-listbalances.7 \
doc/bkpr-listincome.7 \
doc/blacklistrune.7 \
+ doc/cancelrecurringinvoice.7 \
doc/check.7 \
doc/checkmessage.7 \
doc/checkrune.7 \
diff --git a/doc/index.rst b/doc/index.rst
index a471fc38..50a121be 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -38,6 +38,7 @@ Core Lightning Documentation
bkpr-listbalances <bkpr-listbalances.7.md>
bkpr-listincome <bkpr-listincome.7.md>
blacklistrune <blacklistrune.7.md>
+ cancelrecurringinvoice <cancelrecurringinvoice.7.md>
check <check.7.md>
checkmessage <checkmessage.7.md>
checkrune <checkrune.7.md>
diff --git a/doc/schemas/cancelrecurringinvoice.json b/doc/schemas/cancelrecurringinvoice.json
new file mode 100644
index 00000000..4c49f389
--- /dev/null
+++ b/doc/schemas/cancelrecurringinvoice.json
@@ -0,0 +1,81 @@
+{
+ "$schema": "../rpc-schema-draft.json",
+ "type": "object",
+ "added": "v25.09",
+ "rpc": "cancelrecurringinvoice",
+ "title": "Command for sending a cancel message for a recurring offer",
+ "description": [
+ "NOTE: Recurring offers are experimental, and may be changed in backwards-incompable ways.",
+ "",
+ "The **cancelrecurringinvoice** RPC command sends a cancellation message in place of an invoice_request. The BOLT 12 specification suggests sending this as a courtesy in place of the next invoice_request (as would be sent by fetchinvoice)."
+ ],
+ "request": {
+ "required": [
+ "offer",
+ "recurrence_counter",
+ "recurrence_label"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "offer": {
+ "type": "string",
+ "description": [
+ "Offer string (must be recurring) which we have been paying."
+ ]
+ },
+ "recurrence_counter": {
+ "type": "u64",
+ "description": [
+ "One later than the last-specified recurrence_counter for the last invoice."
+ ]
+ },
+ "recurrence_label": {
+ "type": "string",
+ "description": [
+ "This must be the same as prior fetchinvoice calls for the same recurrence, as it is used to link them together."
+ ]
+ },
+ "recurrence_start": {
+ "type": "number",
+ "description": [
+ "Indicates what period number to start at (usually 0). This will be the same as previous fetchinvoice calls."
+ ]
+ },
+ "payer_note": {
+ "type": "string",
+ "description": [
+ "To tell the issuer the reason for the cancellation."
+ ]
+ },
+ "bip353": {
+ "type": "string",
+ "description": [
+ "BIP353 string (optionally with ₿) indicating where we fetched the offer from"
+ ]
+ }
+ }
+ },
+ "response": {
+ "required": [
+ "bolt12"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "bolt12": {
+ "type": "string",
+ "description": [
+ "The invoice_request we sent to the issuer."
+ ]
+ }
+ }
+ },
+ "author": [
+ "Rusty Russell <<rusty@rustcorp.com.au>> is mainly responsible."
+ ],
+ "see_also": [
+ "lightning-fetchinvoice(7)"
+ ],
+ "resources": [
+ "Main web site: <https://github.com/ElementsProject/lightning>"
+ ]
+}
diff --git a/plugins/fetchinvoice.c b/plugins/fetchinvoice.c
index 4965f1ea..ffc7b1b2 100644
--- a/plugins/fetchinvoice.c
+++ b/plugins/fetchinvoice.c
@@ -410,12 +410,23 @@ static struct command_result *timeout_sent_invreq(struct command *timer_cmd,
return timer_complete(timer_cmd);
}
+static struct command_result *cancelrecurringinvoice_done(struct command *cmd,
+ struct sent *sent)
+{
+ return command_success(cmd,
+ json_out_obj(cmd, "bolt12",
+ invrequest_encode(tmpctx, sent->invreq)));
+}
+
static struct command_result *sendonionmsg_done(struct command *cmd,
const char *method UNUSED,
const char *buf UNUSED,
const jsmntok_t *result UNUSED,
struct sent *sent)
{
+ if (sent->invreq && sent->invreq->invreq_recurrence_cancel)
+ return cancelrecurringinvoice_done(cmd, sent);
+
command_timer(cmd,
time_from_sec(sent->wait_timeout),
timeout_sent_invreq, sent);
@@ -768,7 +779,10 @@ static struct command_result *invreq_done(struct command *cmd,
payload->invoice_request = tal_arr(payload, u8, 0);
towire_tlv_invoice_request(&payload->invoice_request, sent->invreq);
- return send_message(cmd, sent, true, payload, sendonionmsg_done);
+ /* Don't expect a reply message for cancel */
+ return send_message(cmd, sent,
+ sent->invreq->invreq_recurrence_cancel ? false : true,
+ payload, sendonionmsg_done);
}
static struct command_result *param_dev_scidd(struct command *cmd, const char *name,
@@ -1137,6 +1151,157 @@ struct command_result *json_fetchinvoice(struct command *cmd,
return send_outreq(req);
}
+struct command_result *json_cancelrecurringinvoice(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *params)
+{
+ const char *rec_label, *payer_note;
+ struct out_req *req;
+ struct tlv_invoice_request *invreq;
+ struct sent *sent = tal(cmd, struct sent);
+ struct bip_353_name *bip353;
+ u32 *recurrence_counter, *recurrence_start;
+
+ if (!param_check(cmd, buffer, params,
+ p_req("offer", param_offer, &sent->offer),
+ p_req("recurrence_counter", param_number, &recurrence_counter),
+ p_req("recurrence_label", param_string, &rec_label),
+ p_opt("recurrence_start", param_number, &recurrence_start),
+ p_opt("payer_note", param_string, &payer_note),
+ p_opt("bip353", param_bip353, &bip353),
+ NULL))
+ return command_param_failed();
+
+ sent->their_paths = sent->offer->offer_paths;
+ if (sent->their_paths)
+ sent->direct_dest = NULL;
+ else
+ sent->direct_dest = sent->offer->offer_issuer_id;
+ /* This is NULL if offer_issuer_id is missing, and set by try_establish */
+ sent->issuer_key = sent->offer->offer_issuer_id;
+
+ /* BOLT #12:
+ * The writer:
+ * - if it is responding to an offer:
+ * - MUST copy all fields from the offer (including unknown fields).
+ */
+ invreq = invoice_request_for_offer(sent, sent->offer);
+ invreq->invreq_recurrence_counter = tal_steal(invreq, recurrence_counter);
+ invreq->invreq_recurrence_start = tal_steal(invreq, recurrence_start);
+ invreq->invreq_bip_353_name = tal_steal(invreq, bip353);
+ invreq->invreq_recurrence_cancel = talz(invreq, struct tlv_invoice_request_invreq_recurrence_cancel);
+
+ /* BOLT-recurrence #12:
+ * - if it sets `invreq_recurrence_cancel`:
+ *...
+ * - MAY omit `invreq_amount` and `invreq_quantity`.
+ */
+ /* And we do */
+ if (!invreq_recurrence(invreq))
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Not a recurring offer");
+
+ /* BOLT-recurrence #12:
+ * - if `offer_recurrence_optional` or `offer_recurrence_compulsory` are present:
+ * - for the initial request:
+ *...
+ * - MUST set `invreq_recurrence_counter` `counter` to 0.
+ * - MUST NOT set `invreq_recurrence_cancel`.
+ */
+ if (*invreq->invreq_recurrence_counter == 0)
+ return command_fail_badparam(cmd, "recurrence_counter", buffer, params,
+ "Must be non-zero");
+
+ /* BOLT-recurrence #12:
+ * - if `offer_recurrence_base` is present:
+ * - MUST include `invreq_recurrence_start`
+ *...
+ * - otherwise:
+ * - MUST NOT include `invreq_recurrence_start`
+ */
+ if (invreq->offer_recurrence_base) {
+ if (!invreq->invreq_recurrence_start)
+ invreq->invreq_recurrence_start = talz(invreq, u32);
+ } else {
+ if (invreq->invreq_recurrence_start)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "unnecessary recurrence_start");
+ }
+
+ invreq->invreq_metadata
+ = recurrence_invreq_metadata(invreq, invreq, rec_label);
+
+ /* We derive transient payer_id from invreq_metadata */
+ invreq->invreq_payer_id = tal(invreq, struct pubkey);
+ if (!payer_key(invreq->invreq_metadata,
+ tal_bytelen(invreq->invreq_metadata),
+ invreq->invreq_payer_id)) {
+ /* Doesn't happen! */
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "Invalid tweak for payer_id");
+ }
+
+ /* BOLT-recurrence #12:
+ * - if `offer_recurrence_base` is present:
+ * - MUST include `invreq_recurrence_start`
+ *...
+ * - otherwise:
+ * - MUST NOT include `invreq_recurrence_start`
+ */
+ if (invreq->offer_recurrence_base) {
+ if (!invreq->invreq_recurrence_start)
+ invreq->invreq_recurrence_start = talz(invreq, u32);
+ } else {
+ if (invreq->invreq_recurrence_start)
+ return command_fail(cmd, JSONRPC2_INVALID_PARAMS,
+ "unnecessary recurrence_start");
+ }
+
+ /* BOLT #12:
+ *
+ * - if `offer_chains` is set:
+ * - MUST set `invreq_chain` to one of `offer_chains` unless that
+ * chain is bitcoin, in which case it SHOULD omit `invreq_chain`.
+ * - otherwise:
+ * - if it sets `invreq_chain` it MUST set it to bitcoin.
+ */
+ /* We already checked that we're compatible chain, in param_offer */
+ if (!streq(chainparams->network_name, "bitcoin")) {
+ invreq->invreq_chain = tal_dup(invreq, struct bitcoin_blkid,
+ &chainparams->genesis_blockhash);
+ }
+
+ /* BOLT #12:
+ * - if it supports bolt12 invoice request features:
+ * - MUST set `invreq_features`.`features` to the bitmap of features.
+ */
+ invreq->invreq_features
+ = plugin_feature_set(cmd->plugin)->bits[BOLT12_OFFER_FEATURE];
+
+ /* invreq->invreq_payer_note is not a nul-terminated string! */
+ if (payer_note)
+ invreq->invreq_payer_note = tal_dup_arr(invreq, utf8,
+ payer_note,
+ strlen(payer_note),
+ 0);
+
+ /* If only checking, we're done now */
+ if (command_check_only(cmd))
+ return command_check_done(cmd);
+
+ /* Make the invoice request (fills in payer_key and payer_info) */
+ req = jsonrpc_request_start(cmd, "createinvoicerequest",
+ &invreq_done,
+ &forward_error,
+ sent);
+
+ /* We don't want this is the database: that's only for ones we publish */
+ json_add_string(req->js, "bolt12", invrequest_encode(tmpctx, invreq));
+ json_add_bool(req->js, "savetodb", false);
+ json_add_string(req->js, "recurrence_label", rec_label);
+ return send_outreq(req);
+}
+
/* FIXME: Using a hook here is not ideal: technically it doesn't mean
* it's actually hit the db! But using waitinvoice is also suboptimal
* because we don't have libplugin infra to cancel a pending req (and I
diff --git a/plugins/fetchinvoice.h b/plugins/fetchinvoice.h
index 6ab7340c..5cbbafca 100644
--- a/plugins/fetchinvoice.h
+++ b/plugins/fetchinvoice.h
@@ -9,6 +9,10 @@ struct command_result *json_fetchinvoice(struct command *cmd,
const char *buffer,
const jsmntok_t *params);
+struct command_result *json_cancelrecurringinvoice(struct command *cmd,
+ const char *buffer,
+ const jsmntok_t *params);
+
struct command_result *json_sendinvoice(struct command *cmd,
const char *buffer,
const jsmntok_t *params);
diff --git a/plugins/offers.c b/plugins/offers.c
index 6076b585..b416f058 100644
--- a/plugins/offers.c
+++ b/plugins/offers.c
@@ -1576,6 +1576,10 @@ static const struct plugin_command commands[] = {
"sendinvoice",
json_sendinvoice,
},
+ {
+ "cancelrecurringinvoice",
+ json_cancelrecurringinvoice,
+ },
{
"dev-rawrequest",
json_dev_rawrequest,
Why this scored 15/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.