offers: limit invoices to 10 minutes for recurring offers in other currencies.
What changed, and why it matters
This change tightens the lifetime of recurring invoices priced in foreign currencies (like USD) to 10 minutes by default, and refreshes them with current exchange rates when they expire. Previously, such invoices could remain valid for the full recurrence period, meaning a merchant or payer could be locked into an outdated exchange rate for a long time. The patch also improves error messages so users can tell the difference between a cancelled invoice and one that simply expired.
Review and deploy; the change is defensive and reduces financial risk from stale currency rates. Operators using recurring currency offers should be aware that invoice lifetimes are now shorter and that the `dev-currency-expiry` option may be renamed or stabilized in future releases.
Security signals we found
Fixes stale exchange-rate exposure for currency-denominated recurring invoices
Adds configurable expiry cap for recurring currency invoices
Distinguishes user-cancellation (0s expiry) from natural expiry to avoid misleading errors
Adds regression test for invoice refresh after currency expiry
Evidence from the diff
The commit adds a new dev option dev-currency-expiry (default 600 seconds) in the offers plugin. For recurring offers denominated in a non-bitcoin currency, set_recurring_inv_expiry() now caps the invoice relative expiry to this value. When an invoice request arrives and an existing expired invoice is found, the plugin decodes it: if relative expiry is 0 it treats it as a user cancellation; otherwise it deletes the stale invoice and re-creates it, picking up the current currency conversion rate. Tests and schema files are updated accordingly.
Changed components
plugins/offers.cplugins/offers.hplugins/offers_invreq_hook.ctests/plugins/currencyUSDAUD5000.pytests/test_pay.pyInspect captured patch +134 / −14
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index a99e1fd7..c3dabf28 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -23100,6 +23100,12 @@
"source": "default",
"plugin": "/usr/local/libexec/plugins/funder"
},
+ "dev-currency-expiry": {
+ "value_int": 600,
+ "source": "default",
+ "plugin": "/usr/local/libexec/plugins/offers",
+ "dynamic": true
+ },
"dev-invoice-bpath-scid": {
"set": false,
"source": "default",
diff --git a/doc/schemas/listconfigs.json b/doc/schemas/listconfigs.json
index 160585ef..662a6413 100644
--- a/doc/schemas/listconfigs.json
+++ b/doc/schemas/listconfigs.json
@@ -2539,6 +2539,12 @@
"source": "default",
"plugin": "/usr/local/libexec/plugins/funder"
},
+ "dev-currency-expiry": {
+ "value_int": 600,
+ "source": "default",
+ "plugin": "/usr/local/libexec/plugins/offers",
+ "dynamic": true
+ },
"dev-invoice-bpath-scid": {
"set": false,
"source": "default",
diff --git a/plugins/offers.c b/plugins/offers.c
index 2e8d6ffd..2cd0c97f 100644
--- a/plugins/offers.c
+++ b/plugins/offers.c
@@ -1995,6 +1995,7 @@ int main(int argc, char *argv[])
od->disable_connect = false;
od->dev_invoice_bpath_scid = false;
od->dev_invoice_internal_scid = NULL;
+ od->dev_currency_expiry = 600;
od->global_gossmap_ = NULL;
/* We deal in UTC; mktime() uses local time */
@@ -2013,5 +2014,8 @@ int main(int argc, char *argv[])
plugin_option_dev("dev-invoice-internal-scid", "string",
"Use short_channel_id instead of pubkey when creating a blinded payment path",
scid_option, scid_jsonfmt, &od->dev_invoice_internal_scid),
+ plugin_option_dev_dynamic("dev-currency-expiry", "int",
+ "Max invoice expiry (seconds) for currency-denominated recurring offers",
+ u32_option, u32_jsonfmt, &od->dev_currency_expiry),
NULL);
}
diff --git a/plugins/offers.h b/plugins/offers.h
index 88f9ceb2..4bc2af32 100644
--- a/plugins/offers.h
+++ b/plugins/offers.h
@@ -29,6 +29,8 @@ struct offers_data {
bool dev_invoice_bpath_scid;
/* --dev-invoice-internal-scid */
struct short_channel_id *dev_invoice_internal_scid;
+ /* --dev-currency-expiry: max invoice expiry for currency offers (default 600) */
+ u32 dev_currency_expiry;
/* Use get_gossmap() to access this! */
struct gossmap *global_gossmap_;
};
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index f9672b06..07f7c50a 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -132,17 +132,22 @@ test_field(struct command *cmd,
* number of seconds after `invoice_created_at` that payment for this period
* will be accepted.
*/
-static void set_recurring_inv_expiry(struct tlv_invoice *inv, u64 last_pay)
+static void set_recurring_inv_expiry(struct command *cmd,
+ struct tlv_invoice *inv, u64 last_pay)
{
+ const struct offers_data *od = get_offers_data(cmd->plugin);
+
inv->invoice_relative_expiry = tal(inv, u32);
- /* Don't give them a 0 second invoice, even if it's true. */
+ /* Don't give them a 0 second invoice, even if it's true: that's how we mark cancellations! */
if (last_pay <= *inv->invoice_created_at)
*inv->invoice_relative_expiry = 1;
else
*inv->invoice_relative_expiry = last_pay - *inv->invoice_created_at;
- /* FIXME: Shorten expiry if we're doing currency conversion! */
+ /* Shorten to dev_currency_expiry (default 10 minutes) for currency conversion. */
+ if (inv->offer_currency && *inv->invoice_relative_expiry > od->dev_currency_expiry)
+ *inv->invoice_relative_expiry = od->dev_currency_expiry;
}
/* We rely on label forms for uniqueness. */
@@ -209,6 +214,19 @@ static struct command_result *createinvoice_done(struct command *cmd,
return send_onion_reply(cmd, ir->reply_path, payload);
}
+static struct command_result *create_invoicereq(struct command *cmd,
+ struct invreq *ir);
+
+static struct command_result *delinvoice_done(struct command *cmd,
+ const char *method,
+ const char *buf,
+ const jsmntok_t *result,
+ struct invreq *ir)
+{
+ /* Old stale-rate invoice deleted; recreate with current rate. */
+ return create_invoicereq(cmd, ir);
+}
+
static struct command_result *createinvoice_error(struct command *cmd,
const char *method,
const char *buf,
@@ -216,19 +234,43 @@ static struct command_result *createinvoice_error(struct command *cmd,
struct invreq *ir)
{
u32 code;
- const char *status;
+ const char *status, *invstring;
/* If it already exists, we can reuse its bolt12 directly. */
if (json_scan(tmpctx, buf, err,
- "{code:%,data:{status:%}}",
+ "{code:%,data:{status:%,bolt12:%}}",
JSON_SCAN(json_to_u32, &code),
- JSON_SCAN_TAL(tmpctx, json_strdup, &status)) == NULL
+ JSON_SCAN_TAL(tmpctx, json_strdup, &status),
+ JSON_SCAN_TAL(tmpctx, json_strdup, &invstring)) == NULL
&& code == INVOICE_LABEL_ALREADY_EXISTS) {
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?)");
+ if (streq(status, "expired")) {
+ struct out_req *req;
+ const char *fail;
+ const struct tlv_invoice *inv;
+
+ inv = invoice_decode(tmpctx, invstring, strlen(invstring),
+ plugin_feature_set(cmd->plugin),
+ chainparams, &fail);
+ /* 0 relative expiry means "they cancelled it" */
+ if (inv && inv->invoice_relative_expiry && *inv->invoice_relative_expiry == 0)
+ return fail_invreq(cmd, ir, "invoice cancelled");
+
+ /* Happens when we shortened expiry for currency
+ * changes. Delete and retry */
+ req = jsonrpc_request_start(cmd, "delinvoice",
+ delinvoice_done,
+ error, ir);
+ json_add_label(req->js, &ir->offer_id,
+ ir->inv->invreq_payer_id,
+ ir->inv->invreq_recurrence_counter
+ ? *ir->inv->invreq_recurrence_counter
+ : 0);
+ json_add_string(req->js, "status", "expired");
+ return send_outreq(req);
+ }
}
return error(cmd, method, buf, err, ir);
}
@@ -238,9 +280,6 @@ static struct command_result *create_invoicereq(struct command *cmd,
{
struct out_req *req;
- /* FIXME: We should add a real blinded path, and we *need to*
- * if we don't have public channels! */
-
/* Now, write invoice to db (returns the signed version) */
req = jsonrpc_request_start(cmd, "createinvoice",
createinvoice_done, createinvoice_error, ir);
@@ -506,7 +545,7 @@ static struct command_result *check_period(struct command *cmd,
paywindow_end);
}
- set_recurring_inv_expiry(ir->inv, paywindow_end);
+ set_recurring_inv_expiry(cmd, ir->inv, paywindow_end);
/* BOLT-recurrence #12:
*
diff --git a/plugins/test/run-decode_guess_type.c b/plugins/test/run-decode_guess_type.c
index 038f3124..bbd1629f 100644
--- a/plugins/test/run-decode_guess_type.c
+++ b/plugins/test/run-decode_guess_type.c
@@ -220,6 +220,13 @@ void rpc_scan(struct command *cmd UNNEEDED,
/* Generated stub for send_outreq */
struct command_result *send_outreq(const struct out_req *req UNNEEDED)
{ fprintf(stderr, "send_outreq called!\n"); abort(); }
+/* Generated stub for u32_jsonfmt */
+bool u32_jsonfmt(struct command *cmd UNNEEDED, struct json_stream *js UNNEEDED, const char *fieldname UNNEEDED,
+ u32 *i UNNEEDED)
+{ fprintf(stderr, "u32_jsonfmt called!\n"); abort(); }
+/* Generated stub for u32_option */
+char *u32_option(struct command *cmd UNNEEDED, const char *arg UNNEEDED, bool check_only UNNEEDED, u32 *i UNNEEDED)
+{ fprintf(stderr, "u32_option called!\n"); abort(); }
/* AUTOGENERATED MOCKS END */
struct likely_test {
diff --git a/tests/plugins/currencyUSDAUD5000.py b/tests/plugins/currencyUSDAUD5000.py
index 3a2f99e9..ba0de479 100755
--- a/tests/plugins/currencyUSDAUD5000.py
+++ b/tests/plugins/currencyUSDAUD5000.py
@@ -5,14 +5,23 @@ This plugin is used to test the currency command
from pyln.client import Plugin, Millisatoshi
plugin = Plugin()
+_rate = 5000 # msat per unit
@plugin.method("currencyconvert")
def currencyconvert(plugin, amount, currency):
"""Converts currency using given APIs."""
if currency in ('USD', 'AUD'):
- return {"msat": Millisatoshi(round(amount * 5000))}
+ return {"msat": Millisatoshi(round(amount * _rate))}
raise Exception("No values available for currency {}".format(currency.upper()))
+@plugin.method("setcurrencyrate")
+def setcurrencyrate(plugin, msat_per_unit):
+ """Change the msat-per-unit rate (for testing)."""
+ global _rate
+ _rate = msat_per_unit
+ return {"msat_per_unit": _rate}
+
+
plugin.run()
diff --git a/tests/test_pay.py b/tests/test_pay.py
index ba9561a2..eb14c3ae 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -4873,6 +4873,53 @@ def test_recurrence_expired_offer(node_factory, bitcoind):
l1.rpc.pay(ret['invoice'], label='test_recurrence_expired_offer')
+def test_recurring_currency_invoice_refresh(node_factory):
+ """After currency-expiry seconds, a new invoice request gets a
+ fresh invoice at the current rate; a request within that window
+ returns the cached invoice.
+
+ """
+ plugin = os.path.join(os.path.dirname(__file__), 'plugins/currencyUSDAUD5000.py')
+ # l2 hosts the offer with a 10-second currency expiry (normally 600s)
+ l1, l2 = node_factory.line_graph(2,
+ opts={'disable-plugin': 'cln-currencyrate',
+ 'plugin': plugin,
+ 'dev-currency-expiry': 10})
+
+ # Period is much longer than the currency expiry so we stay in period 0.
+ offer = l2.rpc.call('offer', {'amount': '1USD',
+ 'description': 'refresh',
+ 'recurrence': '1000seconds'})['bolt12']
+
+ # First fetch: server creates a fresh invoice (1 USD = 5000 msat).
+ inv1 = l1.rpc.call('fetchinvoice', {'offer': offer,
+ 'recurrence_counter': 0,
+ 'recurrence_label': 'refresh'})['invoice']
+
+ # Second fetch within the 10-second window: server returns the same invoice.
+ inv2 = l1.rpc.call('fetchinvoice', {'offer': offer,
+ 'recurrence_counter': 0,
+ 'recurrence_label': 'refresh'})['invoice']
+ assert inv1 == inv2, "Expected identical invoice within currency-expiry window"
+
+ # Change the rate on l2 before the window expires (1 USD = 2500 msat now).
+ l2.rpc.call('setcurrencyrate', {'msat_per_unit': 2500})
+
+ # Wait for the 10-second currency expiry to lapse.
+ time.sleep(11)
+
+ # Third fetch after expiry: server issues a fresh invoice at the new rate.
+ inv3 = l1.rpc.call('fetchinvoice', {'offer': offer,
+ 'recurrence_counter': 0,
+ 'recurrence_label': 'refresh'})['invoice']
+ assert inv3 != inv1, "Expected a fresh invoice after currency-expiry elapsed"
+
+ dec1 = l1.rpc.decode(inv1)
+ dec3 = l1.rpc.decode(inv3)
+ assert dec1['invoice_amount_msat'] == 5000
+ assert dec3['invoice_amount_msat'] == 2500
+
+
def test_fetchinvoice_autoconnect(node_factory, bitcoind):
"""We should autoconnect if we need to, to route."""
@@ -7219,7 +7266,7 @@ def test_cancel_recurrence(node_factory):
assert decoded['invreq_recurrence_cancel'] is True
# Now we cannot fetch second one!
- with pytest.raises(RpcError, match=r"invoice expired \(cancelled\?\)"):
+ with pytest.raises(RpcError, match=r"invoice cancelled"):
l1.rpc.fetchinvoice(offer=offer['bolt12'],
recurrence_counter=1,
recurrence_label='test_cancel_recurrence')
Why this scored 43/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.