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