offers: limit expiry to offer limit, or 10 minutes with currency conversion.
What changed, and why it matters
This commit tightens the expiration time on invoices created from BOLT12 offers. Previously, all such invoices defaulted to a 2-hour payment window. Now, if the offer has an absolute expiry, the invoice expires when the offer expires; and if the offer uses a foreign currency, the invoice expires in 10 minutes (or a developer-tunable value). This reduces the window in which an attacker can exploit stale exchange rates or expired offers.
Review the default dev_currency_expiry value and ensure it is appropriate for production currency conversions; confirm that the absolute-expiry cap correctly handles edge cases where offer_absolute_expiry is in the past or very near invoice_created_at.
Security signals we found
BOLT12 invoice expiry now bounded by offer absolute expiry
Currency-converted invoices use short expiry to limit exchange-rate exposure
New regression test added for expiry behavior
Mirrors a prior fix for recurring offers, indicating a class of issue being addressed
Evidence from the diff
In plugins/offers_invreq_hook.c, handle_amount_and_recurrence now computes a relative expiry instead of always using BOLT12_DEFAULT_REL_EXPIRY (7200 seconds). If the offer has offer_absolute_expiry, rel_expiry is capped to the remaining time until that absolute expiry. If the offer uses offer_currency, rel_expiry is further capped to od->dev_currency_expiry (default 600 seconds / 10 minutes). When the resulting rel_expiry differs from the default, invoice_relative_expiry is set on the invoice. A new Python test verifies plain msat offers keep 7200s, currency offers get <=10s, and absolute-expiry offers get <= remaining seconds.
Changed components
plugins/offers_invreq_hook.ctests/test_pay.pyBOLT12 offer/invoice request handlingInspect captured patch +55 / −2
diff --git a/plugins/offers_invreq_hook.c b/plugins/offers_invreq_hook.c
index 07f7c50a..b6202d33 100644
--- a/plugins/offers_invreq_hook.c
+++ b/plugins/offers_invreq_hook.c
@@ -3,6 +3,7 @@
#include <ccan/cast/cast.h>
#include <ccan/tal/str/str.h>
#include <common/bech32_util.h>
+#include <common/bolt12.h>
#include <common/bolt12_id.h>
#include <common/bolt12_merkle.h>
#include <common/clock_time.h>
@@ -744,6 +745,9 @@ static struct command_result *handle_amount_and_recurrence(struct command *cmd,
struct invreq *ir,
struct amount_msat base_inv_amount)
{
+ const struct offers_data *od = get_offers_data(cmd->plugin);
+ u32 rel_expiry = BOLT12_DEFAULT_REL_EXPIRY;
+
/* BOLT #12:
* - if `invreq_amount` is present:
* - MUST reject the invoice request if `invreq_amount`.`msat` is less than the
@@ -784,8 +788,29 @@ static struct command_result *handle_amount_and_recurrence(struct command *cmd,
if (ir->inv->invreq_recurrence_counter) {
return check_previous_invoice(cmd, ir);
}
- /* We're happy with 2 hours timeout (default): they can always
- * request another. */
+
+ /* Don't allow invoices past expiry of offer. */
+ if (ir->invreq->offer_absolute_expiry) {
+ u64 until = *ir->invreq->offer_absolute_expiry
+ - *ir->inv->invoice_created_at;
+ if (until < rel_expiry)
+ rel_expiry = until;
+ }
+
+ /* And keep them short if currency conversion is involved */
+ if (ir->invreq->offer_currency && od->dev_currency_expiry < rel_expiry)
+ rel_expiry = od->dev_currency_expiry;
+
+ /* BOLT #12:
+ *
+ * - if the expiry for accepting payment is not 7200 seconds after
+ * `invoice_created_at`:
+ * - MUST set `invoice_relative_expiry`.`seconds_from_creation` to
+ * the number of seconds after `invoice_created_at` that payment
+ * of this invoice should not be attempted.
+ */
+ if (rel_expiry != BOLT12_DEFAULT_REL_EXPIRY)
+ ir->inv->invoice_relative_expiry = tal_dup(ir->inv, u32, &rel_expiry);
/* FIXME: Fallbacks? */
return add_blindedpaths(cmd, ir);
diff --git a/tests/test_pay.py b/tests/test_pay.py
index eb14c3ae..cbd44cfa 100644
--- a/tests/test_pay.py
+++ b/tests/test_pay.py
@@ -4759,6 +4759,34 @@ def test_fetchinvoice(node_factory, bitcoind):
l1.rpc.call('fetchinvoice', {'offer': offer1['bolt12'], 'timeout': 10})
+def test_fetchinvoice_invoice_expiry(node_factory, bitcoind):
+ """Non-recurring invoices get a relative_expiry iff the offer has an
+ absolute_expiry or uses a currency (whose conversion rate can change)."""
+ plugin = os.path.join(os.path.dirname(__file__), 'plugins/currencyUSDAUD5000.py')
+ # l2 is the offer node; dev-currency-expiry=2 makes the currency window short.
+ l1, l2 = node_factory.line_graph(2,
+ opts=[{},
+ {'plugin': plugin,
+ 'dev-currency-expiry': 10}])
+
+ # Plain msat offer: no currency, no absolute expiry → default expiry (7200).
+ offer_plain = l2.rpc.offer(amount='1msat', description='plain')
+ inv_plain = l1.rpc.fetchinvoice(offer=offer_plain['bolt12'])
+ assert l1.rpc.decode(inv_plain['invoice'])['invoice_relative_expiry'] == 7200
+
+ # Currency offer: invoice must expire within dev_currency_expiry seconds.
+ offer_usd = l2.rpc.offer(amount='1USD', description='usd')
+ inv_usd = l1.rpc.fetchinvoice(offer=offer_usd['bolt12'])
+ assert l1.rpc.decode(inv_usd['invoice'])['invoice_relative_expiry'] <= 10
+
+ # Absolute-expiry offer: invoice relative_expiry must not exceed time-to-expiry.
+ abs_expiry = int(time.time()) + 10
+ offer_abs = l2.rpc.offer(amount='1msat', description='abs',
+ absolute_expiry=abs_expiry)
+ inv_abs = l1.rpc.fetchinvoice(offer=offer_abs['bolt12'])
+ assert l1.rpc.decode(inv_abs['invoice'])['invoice_relative_expiry'] <= 10
+
+
def test_fetchinvoice_recurrence(node_factory, bitcoind):
"""Test for our recurrence extension"""
l1, l2, l3 = node_factory.line_graph(3, wait_for_announce=True,
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.