plugins/offers: handle invoice_request with invreq_recurrence_cancel
What changed, and why it matters
This commit adds support for cancelling recurring Lightning invoices. Previously, the code rejected invoice requests that lacked a reply path, amount, or quantity. Now, if the request carries the special 'invreq_recurrence_cancel' flag, those requirements are relaxed and the node creates an immediately-expiring invoice instead of returning a usable one. This is a protocol-compliance change that prevents a cancelled recurring invoice from being paid again.
Review the relaxed validation paths to ensure invreq_recurrence_cancel cannot be spoofed or used to bypass unrelated checks, and confirm that the zero-expiry invoice is correctly recorded and cannot be paid.
Security signals we found
Protocol compliance fix for BOLT 12 recurrence cancellation
Relaxation of previously mandatory invoice-request field checks under a specific flag
Creation of zero-expiry invoice to block future payments for a cancelled recurrence
Change in error-handling path when reply_path is missing
Evidence from the diff
The patch modifies the offers plugin to handle BOLT 12 invoice requests containing invreq_recurrence_cancel. It removes the requirement for a reply_path before calling handle_invoice_request, allows missing invreq_amount and invreq_quantity when the cancel flag is set, and introduces cancel_invoice() which sets invoice_relative_expiry to 0 and frees the reply_path. It also suppresses sending the invoice back when reply_path is absent, and handles the case where an invoice with the same label already exists but is expired. A new Python test verifies the end-to-end cancellation flow.
Changed components
plugins/offers.cplugins/offers_invreq_hook.ctests/test_pay.pyInspect captured patch +109 / −25
diff --git a/plugins/offers.c b/plugins/offers.c
index b416f058..ea817c1e 100644
--- a/plugins/offers.c
+++ b/plugins/offers.c
@@ -295,13 +295,9 @@ static struct command_result *onion_message_recv(struct command *cmd,
invreqtok = json_get_member(buf, om, "invoice_request");
if (invreqtok) {
const u8 *invreqbin = json_tok_bin_from_hex(tmpctx, buf, invreqtok);
- if (reply_path)
- return handle_invoice_request(cmd,
- invreqbin,
- reply_path, secret);
- else
- plugin_log(cmd->plugin, LOG_DBG,
- "invoice_request without reply_path");
+ return handle_invoice_request(cmd,
+ invreqbin,
+ reply_path, secret);
}
invtok = json_get_member(buf, om, "invoice");
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index eeb448d5..8714cee9 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -70,6 +70,9 @@ fail_invreq_level(struct command *cmd,
err->error = tal_dup_arr(err, char, msg, strlen(msg), 0);
/* FIXME: Add suggested_value / erroneous_field! */
+ if (!invreq->reply_path)
+ return command_hook_success(cmd);
+
payload = tlv_onionmsg_tlv_new(tmpctx);
payload->invoice_error = tal_arr(payload, u8, 0);
towire_tlv_invoice_error(&payload->invoice_error, err);
@@ -194,6 +197,13 @@ static struct command_result *createinvoice_done(struct command *cmd,
json_tok_full(buf, t));
}
+ /* BOLT-recurrence #12:
+ * - if `invreq_recurrence_cancel` is present:
+ * - MUST NOT send an invoice in reply.
+ */
+ if (!ir->reply_path)
+ return command_hook_success(cmd);
+
payload = tlv_onionmsg_tlv_new(tmpctx);
payload->invoice = tal_steal(payload, rawinv);
return send_onion_reply(cmd, ir->reply_path, payload);
@@ -206,13 +216,19 @@ static struct command_result *createinvoice_error(struct command *cmd,
struct invreq *ir)
{
u32 code;
+ const char *status;
/* If it already exists, we can reuse its bolt12 directly. */
if (json_scan(tmpctx, buf, err,
- "{code:%}", JSON_SCAN(json_to_u32, &code)) == NULL
+ "{code:%,data:{status:%}}",
+ JSON_SCAN(json_to_u32, &code),
+ JSON_SCAN_TAL(tmpctx, json_strdup, &status)) == NULL
&& code == INVOICE_LABEL_ALREADY_EXISTS) {
- return createinvoice_done(cmd, method, buf,
- json_get_member(buf, err, "data"), ir);
+ if (streq(status, "unpaid"))
+ return createinvoice_done(cmd, method, buf,
+ json_get_member(buf, err, "data"), ir);
+ if (streq(status, "expired"))
+ return fail_invreq(cmd, ir, "invoice expired (cancelled?)");
}
return error(cmd, method, buf, err, ir);
}
@@ -372,6 +388,18 @@ static struct command_result *add_blindedpaths(struct command *cmd,
found_best_peer, ir);
}
+static struct command_result *cancel_invoice(struct command *cmd,
+ struct invreq *ir)
+{
+ /* We create an invoice, so we can mark the cancellation, but with
+ * expiry 0. And we don't send it to them! */
+ *ir->inv->invoice_relative_expiry = 0;
+
+ /* In case they set a reply path! */
+ ir->reply_path = tal_free(ir->reply_path);
+ return create_invoicereq(cmd, ir);
+}
+
static struct command_result *check_period(struct command *cmd,
struct invreq *ir,
u64 basetime)
@@ -483,6 +511,10 @@ static struct command_result *check_period(struct command *cmd,
}
}
+ /* If this is actually a cancel, we create an expired invoice */
+ if (ir->invreq->invreq_recurrence_cancel)
+ return cancel_invoice(cmd, ir);
+
return add_blindedpaths(cmd, ir);
}
@@ -626,19 +658,23 @@ static struct command_result *invreq_base_amount_simple(struct command *cmd,
*amt = amount_msat(raw_amount);
} else {
- /* BOLT #12:
+ /* BOLT-recurrence #12:
*
* The reader:
*...
* - otherwise (no `offer_amount`):
- * - MUST reject the invoice request if it does not contain
- * `invreq_amount`.
+ * - MUST reject the invoice request if `invreq_recurrence_cancel`
+ * is not present and it does not contain `invreq_amount`.
*/
- err = invreq_must_have(cmd, ir, invreq_amount);
- if (err)
- return err;
-
- *amt = amount_msat(*ir->invreq->invreq_amount);
+ if (!ir->invreq->invreq_recurrence_cancel) {
+ err = invreq_must_have(cmd, ir, invreq_amount);
+ if (err)
+ return err;
+ }
+ if (ir->invreq->invreq_amount)
+ *amt = amount_msat(*ir->invreq->invreq_amount);
+ else
+ *amt = AMOUNT_MSAT(0);
}
return NULL;
}
@@ -776,6 +812,7 @@ static struct command_result *listoffers_done(struct command *cmd,
bool active;
struct command_result *err;
struct amount_msat amt;
+ struct tlv_invoice_request_invreq_recurrence_cancel *cancel;
/* BOLT #12:
*
@@ -861,9 +898,10 @@ static struct command_result *listoffers_done(struct command *cmd,
return fail_invreq(cmd, ir, "Offer expired");
}
- /* BOLT #12:
+ /* BOLT-recurrence #12:
* - if `offer_quantity_max` is present:
- * - MUST reject the invoice request if there is no `invreq_quantity` field.
+ * - MUST reject the invoice request if `invreq_recurrence_cancel`
+ * is not present and there is no `invreq_quantity` field.
* - if `offer_quantity_max` is non-zero:
* - MUST reject the invoice request if `invreq_quantity` is zero, OR greater than
* `offer_quantity_max`.
@@ -871,15 +909,18 @@ static struct command_result *listoffers_done(struct command *cmd,
* - MUST reject the invoice request if there is an `invreq_quantity` field.
*/
if (ir->invreq->offer_quantity_max) {
- err = invreq_must_have(cmd, ir, invreq_quantity);
- if (err)
- return err;
+ if (!ir->invreq->invreq_recurrence_cancel) {
+ err = invreq_must_have(cmd, ir, invreq_quantity);
+ if (err)
+ return err;
+ }
- if (*ir->invreq->invreq_quantity == 0)
+ if (ir->invreq->invreq_quantity && *ir->invreq->invreq_quantity == 0)
return fail_invreq(cmd, ir,
"quantity zero invalid");
- if (*ir->invreq->offer_quantity_max &&
+ if (ir->invreq->invreq_quantity &&
+ *ir->invreq->offer_quantity_max &&
*ir->invreq->invreq_quantity > *ir->invreq->offer_quantity_max) {
return fail_invreq(cmd, ir,
"quantity %"PRIu64" > %"PRIu64,
@@ -923,6 +964,8 @@ static struct command_result *listoffers_done(struct command *cmd,
* field.
* - MUST reject the invoice request if there is a `invreq_recurrence_start`
* field.
+ * - MUST reject the invoice request if there is a `invreq_recurrence_cancel`
+ * field.
*/
err = invreq_must_not_have(cmd, ir, invreq_recurrence_counter);
if (err)
@@ -930,6 +973,9 @@ static struct command_result *listoffers_done(struct command *cmd,
err = invreq_must_not_have(cmd, ir, invreq_recurrence_start);
if (err)
return err;
+ err = invreq_must_not_have(cmd, ir, invreq_recurrence_cancel);
+ if (err)
+ return err;
}
/* BOLT #12:
@@ -939,8 +985,12 @@ static struct command_result *listoffers_done(struct command *cmd,
* - MUST copy all non-signature fields from the invoice request (including
* unknown fields).
*/
+ /* But "invreq_recurrence_cancel" doesn't exist in invoices, so temporarily remove */
+ cancel = ir->invreq->invreq_recurrence_cancel;
+ ir->invreq->invreq_recurrence_cancel = NULL;
ir->inv = invoice_for_invreq(cmd, ir->invreq);
assert(ir->inv->invreq_payer_id);
+ ir->invreq->invreq_recurrence_cancel = cancel;
/* BOLT #12:
* - if `offer_issuer_id` is present:
diff --git a/tests/test_pay.py b/tests/test_pay.py
index cfb3c23d..1afddd56 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -7021,6 +7021,44 @@ def test_sendonion_sendpay(node_factory, bitcoind):
assert invoice["amount_received_msat"] == Millisatoshi(total_amount)
+def test_cancel_recurrence(node_factory):
+ """Test handling of invoice cancellation"""
+ l1, l2 = node_factory.line_graph(2)
+
+ # Recurring offer.
+ offer = l2.rpc.offer(amount='1msat',
+ description='test_cancel_recurrence',
+ recurrence='1minutes')
+
+ # We cannot cancel if we never got the first one.
+ with pytest.raises(RpcError, match="recurrence_counter: Must be non-zero"):
+ l1.rpc.cancelrecurringinvoice(offer['bolt12'], 0, 'test_cancel_recurrence')
+
+ with pytest.raises(RpcError, match="No previous payment attempted for this label and offer"):
+ l1.rpc.cancelrecurringinvoice(offer['bolt12'], 1, 'test_cancel_recurrence')
+
+ # Fetch and pay first one
+ ret = l1.rpc.fetchinvoice(offer=offer['bolt12'],
+ recurrence_counter=0,
+ recurrence_label='test_cancel_recurrence')
+ l1.rpc.pay(ret['invoice'], label='test_cancel_recurrence')
+
+ # Cancel counter must be correct!
+ with pytest.raises(RpcError, match=r"previous invoice has not been paid \(last was 0\)"):
+ l1.rpc.cancelrecurringinvoice(offer['bolt12'], 2, 'test_cancel_recurrence')
+
+ # Cancel second one.
+ l1.rpc.cancelrecurringinvoice(offer=offer['bolt12'],
+ recurrence_counter=1,
+ recurrence_label='test_cancel_recurrence')
+
+ # Now we cannot fetch second one!
+ with pytest.raises(RpcError, match=r"invoice expired \(cancelled\?\)"):
+ l1.rpc.fetchinvoice(offer=offer['bolt12'],
+ recurrence_counter=1,
+ recurrence_label='test_cancel_recurrence')
+
+
def test_htlc_tlv_crash(node_factory):
"""Marshalling code treated an array of htlc_added as if they were tal objects, but only the head is a tal object so if we have more than one, BOOM!"""
plugin = os.path.join(os.path.dirname(__file__), 'plugins/htlc_accepted-customtlv.py')
Why this scored 45/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.