@@ -422,6 +422,7 @@ macro_rules! invoice_builder_methods {
422422 fallbacks: None ,
423423 features: Bolt12InvoiceFeatures :: empty( ) ,
424424 signing_pubkey,
425+ invoice_recurrence_basetime: None ,
425426 #[ cfg( test) ]
426427 experimental_baz: None ,
427428 }
@@ -438,6 +439,29 @@ macro_rules! invoice_builder_methods {
438439
439440 Ok ( Self { invreq_bytes, invoice: contents, signing_pubkey_strategy } )
440441 }
442+
443+ /// Sets the `invoice_recurrence_basetime` inside the invoice contents.
444+ ///
445+ /// This anchors the recurrence schedule for invoices produced in a
446+ /// recurring-offer flow. Must be identical across all invoices in the
447+ /// same recurrence session.
448+ #[ allow( dead_code) ]
449+ pub ( crate ) fn set_invoice_recurrence_basetime(
450+ & mut $self,
451+ basetime: u64
452+ ) {
453+ match & mut $self. invoice {
454+ InvoiceContents :: ForOffer { fields, .. } => {
455+ fields. invoice_recurrence_basetime = Some ( basetime) ;
456+ } ,
457+ InvoiceContents :: ForRefund { .. } => {
458+ debug_assert!(
459+ false ,
460+ "set_invoice_recurrence_basetime called on refund invoice"
461+ ) ;
462+ }
463+ }
464+ }
441465 } ;
442466}
443467
@@ -773,6 +797,36 @@ struct InvoiceFields {
773797 fallbacks : Option < Vec < FallbackAddress > > ,
774798 features : Bolt12InvoiceFeatures ,
775799 signing_pubkey : PublicKey ,
800+ /// The recurrence anchor time (UNIX timestamp) for this invoice.
801+ ///
802+ /// Semantics:
803+ /// - If the offer specifies an explicit `recurrence_base`, this MUST equal it.
804+ /// - If the offer does not specify a base, this MUST be the creation time
805+ /// of the *first* invoice in the recurrence sequence.
806+ ///
807+ /// Requirements:
808+ /// - The payee must remember the basetime from the first invoice and reuse it
809+ /// for all subsequent invoices in the recurrence.
810+ /// - The payer must verify that the basetime in each invoice matches the
811+ /// basetime of previously paid periods, ensuring a stable schedule.
812+ ///
813+ /// Practical effect:
814+ /// This timestamp anchors the recurrence period calculation for the entire
815+ /// recurring-payment flow.
816+ ///
817+ /// Spec Commentary:
818+ /// The spec currently requires this field even when the offer already includes
819+ /// its own `recurrence_base`. Since invoices are always prsent alongside their
820+ /// offer, the basetime is already known. Duplicating it across offer → invoice
821+ /// adds redundant equivalence checks without providing new information.
822+ ///
823+ /// Possible simplification:
824+ /// - Include `invoice_recurrence_basetime` **only when** the offer did *not* define one.
825+ /// - Omit it otherwise and treat the offer as the single source of truth.
826+ ///
827+ /// This avoids redundant duplication and simplifies validation while preserving
828+ /// all necessary semantics.
829+ invoice_recurrence_basetime : Option < u64 > ,
776830 #[ cfg( test) ]
777831 experimental_baz : Option < u64 > ,
778832}
@@ -1402,6 +1456,7 @@ impl InvoiceFields {
14021456 features,
14031457 node_id : Some ( & self . signing_pubkey ) ,
14041458 message_paths : None ,
1459+ invoice_recurrence_basetime : self . invoice_recurrence_basetime ,
14051460 } ,
14061461 ExperimentalInvoiceTlvStreamRef {
14071462 #[ cfg( test) ]
@@ -1483,6 +1538,7 @@ tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, {
14831538 ( 172 , fallbacks: ( Vec <FallbackAddress >, WithoutLength ) ) ,
14841539 ( 174 , features: ( Bolt12InvoiceFeatures , WithoutLength ) ) ,
14851540 ( 176 , node_id: PublicKey ) ,
1541+ ( 177 , invoice_recurrence_basetime: ( u64 , HighZeroBytesDroppedBigSize ) ) ,
14861542 // Only present in `StaticInvoice`s.
14871543 ( 236 , message_paths: ( Vec <BlindedMessagePath >, WithoutLength ) ) ,
14881544} ) ;
@@ -1674,6 +1730,7 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
16741730 features,
16751731 node_id,
16761732 message_paths,
1733+ invoice_recurrence_basetime,
16771734 } ,
16781735 experimental_offer_tlv_stream,
16791736 experimental_invoice_request_tlv_stream,
@@ -1713,13 +1770,19 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
17131770 fallbacks,
17141771 features,
17151772 signing_pubkey,
1773+ invoice_recurrence_basetime,
17161774 #[ cfg( test) ]
17171775 experimental_baz,
17181776 } ;
17191777
17201778 check_invoice_signing_pubkey ( & fields. signing_pubkey , & offer_tlv_stream) ?;
17211779
17221780 if offer_tlv_stream. issuer_id . is_none ( ) && offer_tlv_stream. paths . is_none ( ) {
1781+ // Recurrence should not be present in Refund.
1782+ if fields. invoice_recurrence_basetime . is_some ( ) {
1783+ return Err ( Bolt12SemanticError :: InvalidAmount ) ;
1784+ }
1785+
17231786 let refund = RefundContents :: try_from ( (
17241787 payer_tlv_stream,
17251788 offer_tlv_stream,
@@ -1742,6 +1805,61 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
17421805 experimental_invoice_request_tlv_stream,
17431806 ) ) ?;
17441807
1808+ // Recurrence checks
1809+ if let Some ( offer_recurrence) = invoice_request. inner . offer . recurrence_fields ( ) {
1810+ // 1. MUST have basetime whenever offer has recurrence (optional or compulsory).
1811+ let invoice_basetime = match fields. invoice_recurrence_basetime {
1812+ Some ( ts) => ts,
1813+ None => {
1814+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1815+ } ,
1816+ } ;
1817+
1818+ let offer_base = offer_recurrence. recurrence_base ;
1819+ let counter = invoice_request. recurrence_counter ( ) ;
1820+
1821+ match counter {
1822+ // ----------------------------------------------------------------------
1823+ // Case A: No counter (payer does NOT support recurrence)
1824+ // Treat as single-payment invoice.
1825+ // Basetime MUST still match presence rules (spec), but nothing else here.
1826+ // ----------------------------------------------------------------------
1827+ None => {
1828+ // Nothing else to validate.
1829+ // This invoice is not part of a recurrence sequence.
1830+ } ,
1831+ // ------------------------------------------------------------------
1832+ // Case B: First recurrence invoice (counter = 0)
1833+ // ------------------------------------------------------------------
1834+ Some ( 0 ) => {
1835+ match offer_base {
1836+ // Offer defines explicit basetime → MUST match exactly
1837+ Some ( base) => {
1838+ if invoice_basetime != base. basetime {
1839+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1840+ }
1841+ } ,
1842+
1843+ // Offer has no basetime → MUST match invoice.created_at
1844+ None => {
1845+ if invoice_basetime != fields. created_at . as_secs ( ) {
1846+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1847+ }
1848+ } ,
1849+ }
1850+ } ,
1851+ // ------------------------------------------------------------------
1852+ // Case C: Successive recurrence invoices (counter > 0)
1853+ // ------------------------------------------------------------------
1854+ Some ( _counter_gt_0) => {
1855+ // Spec says SHOULD check equality with previous invoice basetime.
1856+ // We cannot enforce that here. MUST be done upstream.
1857+ //
1858+ // TODO: Enforce SHOULD: invoice_basetime == previous_invoice_basetime
1859+ } ,
1860+ }
1861+ }
1862+
17451863 if let Some ( requested_amount_msats) = invoice_request. amount_msats ( ) {
17461864 if amount_msats != requested_amount_msats {
17471865 return Err ( Bolt12SemanticError :: InvalidAmount ) ;
@@ -2019,6 +2137,7 @@ mod tests {
20192137 features: None ,
20202138 node_id: Some ( & recipient_pubkey( ) ) ,
20212139 message_paths: None ,
2140+ invoice_recurrence_basetime: None ,
20222141 } ,
20232142 SignatureTlvStreamRef { signature: Some ( & invoice. signature( ) ) } ,
20242143 ExperimentalOfferTlvStreamRef { experimental_foo: None } ,
@@ -2130,6 +2249,7 @@ mod tests {
21302249 features: None ,
21312250 node_id: Some ( & recipient_pubkey( ) ) ,
21322251 message_paths: None ,
2252+ invoice_recurrence_basetime: None ,
21332253 } ,
21342254 SignatureTlvStreamRef { signature: Some ( & invoice. signature( ) ) } ,
21352255 ExperimentalOfferTlvStreamRef { experimental_foo: None } ,
0 commit comments