Skip to content

Commit 4877434

Browse files
committed
Introduce recurrence fields in InvoiceRequest (BOLT12 PoC)
This commit adds the recurrence-related TLVs to `InvoiceRequest`, allowing payers to specify the intended period index, an optional starting offset, and (when applicable) a recurrence cancellation signal. Spec reference: https://github.com/rustyrussell/bolts/blob/guilt/offers-recurrence/12-offer-encoding.md#tlv-fields-for-invoice_request
1 parent d6dd3c5 commit 4877434

File tree

5 files changed

+225
-1
lines changed

5 files changed

+225
-1
lines changed

fuzz/src/invoice_request_deser.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
9898
.payer_note()
9999
.map(|s| UntrustedString(s.to_string())),
100100
human_readable_name: None,
101+
recurrence_counter: None,
101102
}
102103
};
103104

lightning/src/ln/offers_tests.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() {
683683
quantity: None,
684684
payer_note_truncated: None,
685685
human_readable_name: None,
686+
recurrence_counter: None,
686687
},
687688
});
688689
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -841,6 +842,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
841842
quantity: None,
842843
payer_note_truncated: None,
843844
human_readable_name: None,
845+
recurrence_counter: None,
844846
},
845847
});
846848
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -962,6 +964,7 @@ fn pays_for_offer_without_blinded_paths() {
962964
quantity: None,
963965
payer_note_truncated: None,
964966
human_readable_name: None,
967+
recurrence_counter: None,
965968
},
966969
});
967970

@@ -1229,6 +1232,7 @@ fn creates_and_pays_for_offer_with_retry() {
12291232
quantity: None,
12301233
payer_note_truncated: None,
12311234
human_readable_name: None,
1235+
recurrence_counter: None,
12321236
},
12331237
});
12341238
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -1294,6 +1298,7 @@ fn pays_bolt12_invoice_asynchronously() {
12941298
quantity: None,
12951299
payer_note_truncated: None,
12961300
human_readable_name: None,
1301+
recurrence_counter: None,
12971302
},
12981303
});
12991304

@@ -1391,6 +1396,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() {
13911396
quantity: None,
13921397
payer_note_truncated: None,
13931398
human_readable_name: None,
1399+
recurrence_counter: None,
13941400
},
13951401
});
13961402
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);

lightning/src/offers/invoice.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,6 +2002,9 @@ mod tests {
20022002
payer_note: None,
20032003
paths: None,
20042004
offer_from_hrn: None,
2005+
recurrence_counter: None,
2006+
recurrence_start: None,
2007+
recurrence_cancel: None,
20052008
},
20062009
InvoiceTlvStreamRef {
20072010
paths: Some(Iterable(
@@ -2110,6 +2113,9 @@ mod tests {
21102113
payer_note: None,
21112114
paths: None,
21122115
offer_from_hrn: None,
2116+
recurrence_counter: None,
2117+
recurrence_start: None,
2118+
recurrence_cancel: None,
21132119
},
21142120
InvoiceTlvStreamRef {
21152121
paths: Some(Iterable(

lightning/src/offers/invoice_request.rs

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,8 @@ macro_rules! invoice_request_builder_methods { (
186186
InvoiceRequestContentsWithoutPayerSigningPubkey {
187187
payer: PayerContents(metadata), offer, chain: None, amount_msats: None,
188188
features: InvoiceRequestFeatures::empty(), quantity: None, payer_note: None,
189-
offer_from_hrn: None,
189+
offer_from_hrn: None, recurrence_counter: None, recurrence_start: None,
190+
recurrence_cancel: None,
190191
#[cfg(test)]
191192
experimental_bar: None,
192193
}
@@ -686,6 +687,36 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey {
686687
quantity: Option<u64>,
687688
payer_note: Option<String>,
688689
offer_from_hrn: Option<HumanReadableName>,
690+
/// Recurrence counter for this invoice request.
691+
///
692+
/// This is the Nth invoice request the payer is making for this offer.
693+
/// Important: this does *not* necessarily equal the Nth period of the recurrence.
694+
///
695+
/// The actual period index is:
696+
/// period_index = recurrence_start + recurrence_counter
697+
///
698+
/// The counter implicitly assumes that all earlier payments
699+
/// (0 .. recurrence_counter-1) were successfully completed.
700+
/// The payee does not track past payments; it simply verifies
701+
/// that the incoming counter is the next expected one.
702+
recurrence_counter: Option<u32>,
703+
/// Starting offset into the recurrence schedule.
704+
///
705+
/// Example: If the offer has a basetime of Jan 1st and recurrence period
706+
/// is monthly, and the payer wants to begin on April 1st, then:
707+
/// recurrence_start = 3
708+
///
709+
/// This field is only meaningful for offers that define a `recurrence_base`,
710+
/// since offset is defined relative to a fixed basetime.
711+
recurrence_start: Option<u32>,
712+
/// Indicates that the payer wishes to *cancel* the recurrence.
713+
///
714+
/// MUST NOT be set on the first invoice request (counter = 0).
715+
///
716+
/// When this field is present, the request is effectively a cancellation
717+
/// message; the payee should send invoice corresponding to this stub
718+
/// invoice_request.
719+
recurrence_cancel: Option<()>,
689720
#[cfg(test)]
690721
experimental_bar: Option<u64>,
691722
}
@@ -736,6 +767,30 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => {
736767
$contents.payer_signing_pubkey()
737768
}
738769

770+
/// Returns the recurrence counter for this invoice request, if present.
771+
///
772+
/// This indicates which request in the recurrence sequence this is.
773+
/// `None` means the invoice request is not part of a recurrence flow.
774+
pub fn recurrence_counter(&$self) -> Option<u32> {
775+
$contents.recurrence_counter()
776+
}
777+
778+
/// Returns the recurrence start offset, if present.
779+
///
780+
/// This is only set when the offer defines an absolute recurrence basetime.
781+
/// It indicates from which period the payer wishes to begin.
782+
pub fn recurrence_start(&$self) -> Option<u32> {
783+
$contents.recurrence_start()
784+
}
785+
786+
/// Returns whether this invoice request is cancelling an ongoing recurrence.
787+
///
788+
/// `Some(())` means the payer wishes to cancel.
789+
/// This MUST NOT be set on the initial request in a recurrence sequence.
790+
pub fn recurrence_cancel(&$self) -> Option<()> {
791+
$contents.recurrence_cancel()
792+
}
793+
739794
/// A payer-provided note which will be seen by the recipient and reflected back in the invoice
740795
/// response.
741796
pub fn payer_note(&$self) -> Option<PrintableString<'_>> {
@@ -1050,6 +1105,7 @@ macro_rules! fields_accessor {
10501105
inner: InvoiceRequestContentsWithoutPayerSigningPubkey {
10511106
quantity,
10521107
payer_note,
1108+
recurrence_counter,
10531109
..
10541110
},
10551111
} = &$inner;
@@ -1063,6 +1119,7 @@ macro_rules! fields_accessor {
10631119
// down to the nearest valid UTF-8 code point boundary.
10641120
.map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))),
10651121
human_readable_name: $self.offer_from_hrn().clone(),
1122+
recurrence_counter: *recurrence_counter,
10661123
}
10671124
}
10681125
};
@@ -1167,6 +1224,18 @@ impl InvoiceRequestContents {
11671224
self.inner.quantity
11681225
}
11691226

1227+
pub(super) fn recurrence_counter(&self) -> Option<u32> {
1228+
self.inner.recurrence_counter
1229+
}
1230+
1231+
pub(super) fn recurrence_start(&self) -> Option<u32> {
1232+
self.inner.recurrence_start
1233+
}
1234+
1235+
pub(super) fn recurrence_cancel(&self) -> Option<()> {
1236+
self.inner.recurrence_cancel
1237+
}
1238+
11701239
pub(super) fn payer_signing_pubkey(&self) -> PublicKey {
11711240
self.payer_signing_pubkey
11721241
}
@@ -1222,6 +1291,9 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey {
12221291
payer_note: self.payer_note.as_ref(),
12231292
offer_from_hrn: self.offer_from_hrn.as_ref(),
12241293
paths: None,
1294+
recurrence_counter: self.recurrence_counter,
1295+
recurrence_start: self.recurrence_start,
1296+
recurrence_cancel: self.recurrence_cancel.as_ref(),
12251297
};
12261298

12271299
let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef {
@@ -1282,6 +1354,9 @@ tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef<'a>, INVOICE_REQ
12821354
// Only used for Refund since the onion message of an InvoiceRequest has a reply path.
12831355
(90, paths: (Vec<BlindedMessagePath>, WithoutLength)),
12841356
(91, offer_from_hrn: HumanReadableName),
1357+
(92, recurrence_counter: (u32, HighZeroBytesDroppedBigSize)),
1358+
(93, recurrence_start: (u32, HighZeroBytesDroppedBigSize)),
1359+
(94, recurrence_cancel: ()),
12851360
});
12861361

12871362
/// Valid type range for experimental invoice_request TLV records.
@@ -1434,6 +1509,9 @@ impl TryFrom<PartialInvoiceRequestTlvStream> for InvoiceRequestContents {
14341509
payer_note,
14351510
paths,
14361511
offer_from_hrn,
1512+
recurrence_counter,
1513+
recurrence_start,
1514+
recurrence_cancel,
14371515
},
14381516
experimental_offer_tlv_stream,
14391517
ExperimentalInvoiceRequestTlvStream {
@@ -1470,6 +1548,102 @@ impl TryFrom<PartialInvoiceRequestTlvStream> for InvoiceRequestContents {
14701548
return Err(Bolt12SemanticError::UnexpectedPaths);
14711549
}
14721550

1551+
let offer_recurrence = offer.recurrence_fields();
1552+
let offer_base = offer_recurrence.and_then(|f| f.recurrence_base);
1553+
1554+
match (
1555+
offer_recurrence,
1556+
offer_base,
1557+
recurrence_counter,
1558+
recurrence_start,
1559+
recurrence_cancel,
1560+
) {
1561+
// Offer without recurrence → No recurrence fields should be in IR
1562+
(None, None, None, None, None) => { /* OK */ },
1563+
// ------------------------------------------------------------
1564+
// Recurrence OPTIONAL (no basetime)
1565+
// ------------------------------------------------------------
1566+
// 1. No fields → treat as normal single payment. Supports backward compatibility.
1567+
// Spec Suggestion:
1568+
//
1569+
// Currently the reader MUST reject any invoice_request that omits
1570+
// `invreq_recurrence_counter` when the offer contains recurrence_optional
1571+
// or recurrence_compulsory.
1572+
// However, recurrence_optional is explicitly intended to preserve
1573+
// compatibility with payers that do not implement recurrence. Such payers
1574+
// should be able to make a single, non-recurring payment without setting
1575+
// any recurrence fields.
1576+
// Therefore, for recurrence_optional, it should be valid to omit all
1577+
// recurrence-related fields (counter, start, cancel), and the invoice
1578+
// request should be treated as a normal single payment.
1579+
(Some(_), None, None, None, None) => { /* OK */ },
1580+
// 2. Only counter → payer supports recurrence; starting at counter
1581+
(Some(_), None, Some(_), None, None) => { /* OK */ },
1582+
// 3. counter > 0 → allowed cancellation
1583+
(Some(_), None, Some(c), None, Some(())) if c > 0 => { /* OK */ },
1584+
// INVALID optional cases:
1585+
(Some(_), None, _, Some(_), _) => {
1586+
// recurrence_start MUST NOT appear without basetime
1587+
return Err(Bolt12SemanticError::InvalidMetadata);
1588+
},
1589+
(Some(_), None, Some(c), None, Some(())) if c == 0 => {
1590+
// cannot cancel first request
1591+
return Err(Bolt12SemanticError::InvalidMetadata);
1592+
},
1593+
(Some(_), None, _, _, _) => {
1594+
// All other recurrence optional combinations invalid
1595+
return Err(Bolt12SemanticError::InvalidMetadata);
1596+
},
1597+
// ------------------------------------------------------------
1598+
// Recurrence COMPULSORY (with basetime)
1599+
// ------------------------------------------------------------
1600+
1601+
// 1. First request: counter=0, start present, cancel absent
1602+
(Some(_), Some(_), Some(0), Some(_), None) => { /* OK */ },
1603+
// 2. Later periods: counter>0, start present, cancel MAY be present
1604+
(Some(_), Some(_), Some(c), Some(_), _cancel) if c > 0 => { /* OK */ },
1605+
1606+
// INVALID compulsory cases ------------------------------------
1607+
// Missing counter or start
1608+
(Some(_), Some(_), None, _, _) | (Some(_), Some(_), _, None, _) => {
1609+
return Err(Bolt12SemanticError::InvalidMetadata);
1610+
},
1611+
// Cancel on first request (counter=0)
1612+
(Some(_), Some(_), Some(c), Some(_), Some(())) if c == 0 => {
1613+
return Err(Bolt12SemanticError::InvalidMetadata);
1614+
},
1615+
// Any other recurrence compulsory combination is invalid
1616+
(Some(_), Some(_), _, _, _) => {
1617+
return Err(Bolt12SemanticError::InvalidMetadata);
1618+
},
1619+
// Any other combination is invalid
1620+
(_, _, _, _, _) => {
1621+
return Err(Bolt12SemanticError::InvalidMetadata);
1622+
},
1623+
}
1624+
1625+
// Limit, and Paywindow checks.
1626+
if let Some(fields) = &offer_recurrence {
1627+
if let Some(limit) = fields.recurrence_limit {
1628+
// Only enforce limit when recurrence is actually in use.
1629+
if let Some(counter) = recurrence_counter {
1630+
let offset = recurrence_start.unwrap_or(0);
1631+
let period_index = counter + offset;
1632+
1633+
if period_index > limit.0 {
1634+
return Err(Bolt12SemanticError::InvalidMetadata);
1635+
}
1636+
}
1637+
}
1638+
if let Some(_paywindow) = fields.recurrence_paywindow {
1639+
// TODO: implement once we compute:
1640+
// let period_start_time = ...
1641+
//
1642+
// if now < period_start_time - paywindow.seconds_before { ... }
1643+
// if now >= period_start_time + paywindow.seconds_after { ... }
1644+
}
1645+
}
1646+
14731647
Ok(InvoiceRequestContents {
14741648
inner: InvoiceRequestContentsWithoutPayerSigningPubkey {
14751649
payer,
@@ -1480,6 +1654,9 @@ impl TryFrom<PartialInvoiceRequestTlvStream> for InvoiceRequestContents {
14801654
quantity,
14811655
payer_note,
14821656
offer_from_hrn,
1657+
recurrence_counter,
1658+
recurrence_start,
1659+
recurrence_cancel,
14831660
#[cfg(test)]
14841661
experimental_bar,
14851662
},
@@ -1505,6 +1682,19 @@ pub struct InvoiceRequestFields {
15051682

15061683
/// The Human Readable Name which the sender indicated they were paying to.
15071684
pub human_readable_name: Option<HumanReadableName>,
1685+
1686+
/// If the invoice request belonged to a recurring offer, this field
1687+
/// contains the *recurrence counter* (zero-based).
1688+
///
1689+
/// Semantics:
1690+
/// - `None` means this payment is not part of a recurrence (either a
1691+
/// one-off request, or the payer does not understand recurrence).
1692+
/// - `Some(n)` means this payment corresponds to period `n`, where
1693+
/// `n` matches the invoice request's `invreq_recurrence_counter`.
1694+
///
1695+
/// This is consumed by the payee when the payment is actually claimed,
1696+
/// allowing the recurrence state to advance (`next_payable_counter += 1`).
1697+
pub recurrence_counter: Option<u32>,
15081698
}
15091699

15101700
/// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`].
@@ -1522,6 +1712,7 @@ impl Writeable for InvoiceRequestFields {
15221712
(1, self.human_readable_name, option),
15231713
(2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option),
15241714
(4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option),
1715+
(6, self.recurrence_counter.map(|v| HighZeroBytesDroppedBigSize(v)), option),
15251716
});
15261717
Ok(())
15271718
}
@@ -1534,13 +1725,15 @@ impl Readable for InvoiceRequestFields {
15341725
(1, human_readable_name, option),
15351726
(2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))),
15361727
(4, payer_note_truncated, (option, encoding: (String, WithoutLength))),
1728+
(6, recurrence_counter, (option, encoding: (u32, HighZeroBytesDroppedBigSize))),
15371729
});
15381730

15391731
Ok(InvoiceRequestFields {
15401732
payer_signing_pubkey: payer_signing_pubkey.0.unwrap(),
15411733
quantity,
15421734
payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)),
15431735
human_readable_name,
1736+
recurrence_counter,
15441737
})
15451738
}
15461739
}
@@ -1662,6 +1855,9 @@ mod tests {
16621855
payer_note: None,
16631856
paths: None,
16641857
offer_from_hrn: None,
1858+
recurrence_counter: None,
1859+
recurrence_start: None,
1860+
recurrence_cancel: None,
16651861
},
16661862
SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) },
16671863
ExperimentalOfferTlvStreamRef { experimental_foo: None },
@@ -3117,6 +3313,7 @@ mod tests {
31173313
quantity: Some(1),
31183314
payer_note_truncated: Some(UntrustedString(expected_payer_note)),
31193315
human_readable_name: None,
3316+
recurrence_counter: None,
31203317
}
31213318
);
31223319

0 commit comments

Comments
 (0)