From 0d18b82dae13e70178b8cd9e261788a4ecbb97e6 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Mon, 29 Sep 2025 13:21:37 +0930 Subject: [PATCH] plugins: `cancelrecurringinvoice` command. `fetchinvoice` variant, for setting invreq_recurrence_cancel instead. Signed-off-by: Rusty Russell Changelog-EXPERIMENTAL: `cancelrecurringinvoice` command to send new "don't expect any more invoice requests" msg to recurring bolt12 invoices. --- contrib/msggen/msggen/schema.json | 81 ++++++++++++ doc/Makefile | 1 + doc/index.rst | 1 + doc/schemas/cancelrecurringinvoice.json | 81 ++++++++++++ plugins/fetchinvoice.c | 167 +++++++++++++++++++++++- plugins/fetchinvoice.h | 4 + plugins/offers.c | 4 + 7 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 doc/schemas/cancelrecurringinvoice.json diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json index 7834628d0..a0f42b9c8 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 <> is mainly responsible." + ], + "see_also": [ + "lightning-fetchinvoice(7)" + ], + "resources": [ + "Main web site: " + ] + }, "check.json": { "$schema": "../rpc-schema-draft.json", "type": "object", diff --git a/doc/Makefile b/doc/Makefile index 7c8acd36e..b8498984e 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 a471fc38f..50a121be0 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -38,6 +38,7 @@ Core Lightning Documentation bkpr-listbalances bkpr-listincome blacklistrune + cancelrecurringinvoice check checkmessage checkrune diff --git a/doc/schemas/cancelrecurringinvoice.json b/doc/schemas/cancelrecurringinvoice.json new file mode 100644 index 000000000..4c49f3893 --- /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 <> is mainly responsible." + ], + "see_also": [ + "lightning-fetchinvoice(7)" + ], + "resources": [ + "Main web site: " + ] +} diff --git a/plugins/fetchinvoice.c b/plugins/fetchinvoice.c index 4965f1ea8..ffc7b1b2d 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 6ab7340ce..5cbbafca5 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 6076b5857..b416f058b 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,