plugins: cancelrecurringinvoice command.

`fetchinvoice` variant, for setting invreq_recurrence_cancel instead.

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Changelog-EXPERIMENTAL: `cancelrecurringinvoice` command to send new "don't expect any more invoice requests" msg to recurring bolt12 invoices.
This commit is contained in:
Rusty Russell
2025-09-29 13:21:37 +09:30
parent 5a73bd34e5
commit 0d18b82dae
7 changed files with 338 additions and 1 deletions

View File

@@ -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",

View File

@@ -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 \

View File

@@ -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>

View File

@@ -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>"
]
}

View File

@@ -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

View File

@@ -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);

View File

@@ -1576,6 +1576,10 @@ static const struct plugin_command commands[] = {
"sendinvoice",
json_sendinvoice,
},
{
"cancelrecurringinvoice",
json_cancelrecurringinvoice,
},
{
"dev-rawrequest",
json_dev_rawrequest,