@@ -255,6 +255,7 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods {
255255 amount_msats,
256256 signing_pubkey,
257257 ) ,
258+ invoice_recurrence_basetime: None ,
258259 } ;
259260
260261 Self :: new( & invoice_request. bytes, contents, ExplicitSigningPubkey { } )
@@ -328,6 +329,7 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods {
328329 amount_msats,
329330 signing_pubkey,
330331 ) ,
332+ invoice_recurrence_basetime: None ,
331333 } ;
332334
333335 Self :: new( & invoice_request. bytes, contents, DerivedSigningPubkey ( keys) )
@@ -438,6 +440,29 @@ macro_rules! invoice_builder_methods {
438440
439441 Ok ( Self { invreq_bytes, invoice: contents, signing_pubkey_strategy } )
440442 }
443+
444+ /// Sets the `invoice_recurrence_basetime` inside the invoice contents.
445+ ///
446+ /// This anchors the recurrence schedule for invoices produced in a
447+ /// recurring-offer flow. Must be identical across all invoices in the
448+ /// same recurrence session.
449+ #[ allow( dead_code) ]
450+ pub ( crate ) fn set_invoice_recurrence_basetime(
451+ & mut $self,
452+ basetime: u64
453+ ) {
454+ match & mut $self. invoice {
455+ InvoiceContents :: ForOffer { invoice_recurrence_basetime, .. } => {
456+ * invoice_recurrence_basetime = Some ( basetime) ;
457+ } ,
458+ InvoiceContents :: ForRefund { .. } => {
459+ debug_assert!(
460+ false ,
461+ "set_invoice_recurrence_basetime called on refund invoice"
462+ ) ;
463+ }
464+ }
465+ }
441466 } ;
442467}
443468
@@ -755,7 +780,40 @@ enum InvoiceContents {
755780 /// Contents for an [`Bolt12Invoice`] corresponding to an [`Offer`].
756781 ///
757782 /// [`Offer`]: crate::offers::offer::Offer
758- ForOffer { invoice_request : InvoiceRequestContents , fields : InvoiceFields } ,
783+ ForOffer {
784+ invoice_request : InvoiceRequestContents ,
785+ fields : InvoiceFields ,
786+ /// The recurrence anchor time (UNIX timestamp) for this invoice.
787+ ///
788+ /// Semantics:
789+ /// - If the offer specifies an explicit `recurrence_base`, this MUST equal it.
790+ /// - If the offer does not specify a base, this MUST be the creation time
791+ /// of the *first* invoice in the recurrence sequence.
792+ ///
793+ /// Requirements:
794+ /// - The payee must remember the basetime from the first invoice and reuse it
795+ /// for all subsequent invoices in the recurrence.
796+ /// - The payer must verify that the basetime in each invoice matches the
797+ /// basetime of previously paid periods, ensuring a stable schedule.
798+ ///
799+ /// Practical effect:
800+ /// This timestamp anchors the recurrence period calculation for the entire
801+ /// recurring-payment flow.
802+ ///
803+ /// Spec Commentary:
804+ /// The spec currently requires this field even when the offer already includes
805+ /// its own `recurrence_base`. Since invoices are always prsent alongside their
806+ /// offer, the basetime is already known. Duplicating it across offer → invoice
807+ /// adds redundant equivalence checks without providing new information.
808+ ///
809+ /// Possible simplification:
810+ /// - Include `invoice_recurrence_basetime` **only when** the offer did *not* define one.
811+ /// - Omit it otherwise and treat the offer as the single source of truth.
812+ ///
813+ /// This avoids redundant duplication and simplifies validation while preserving
814+ /// all necessary semantics.
815+ invoice_recurrence_basetime : Option < u64 > ,
816+ } ,
759817 /// Contents for an [`Bolt12Invoice`] corresponding to a [`Refund`].
760818 ///
761819 /// [`Refund`]: crate::offers::refund::Refund
@@ -1402,6 +1460,7 @@ impl InvoiceFields {
14021460 features,
14031461 node_id : Some ( & self . signing_pubkey ) ,
14041462 message_paths : None ,
1463+ invoice_recurrence_basetime : None ,
14051464 } ,
14061465 ExperimentalInvoiceTlvStreamRef {
14071466 #[ cfg( test) ]
@@ -1483,6 +1542,7 @@ tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, {
14831542 ( 172 , fallbacks: ( Vec <FallbackAddress >, WithoutLength ) ) ,
14841543 ( 174 , features: ( Bolt12InvoiceFeatures , WithoutLength ) ) ,
14851544 ( 176 , node_id: PublicKey ) ,
1545+ ( 177 , invoice_recurrence_basetime: ( u64 , HighZeroBytesDroppedBigSize ) ) ,
14861546 // Only present in `StaticInvoice`s.
14871547 ( 236 , message_paths: ( Vec <BlindedMessagePath >, WithoutLength ) ) ,
14881548} ) ;
@@ -1674,6 +1734,7 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
16741734 features,
16751735 node_id,
16761736 message_paths,
1737+ invoice_recurrence_basetime,
16771738 } ,
16781739 experimental_offer_tlv_stream,
16791740 experimental_invoice_request_tlv_stream,
@@ -1720,6 +1781,11 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
17201781 check_invoice_signing_pubkey ( & fields. signing_pubkey , & offer_tlv_stream) ?;
17211782
17221783 if offer_tlv_stream. issuer_id . is_none ( ) && offer_tlv_stream. paths . is_none ( ) {
1784+ // Recurrence should not be present in Refund.
1785+ if invoice_recurrence_basetime. is_some ( ) {
1786+ return Err ( Bolt12SemanticError :: InvalidAmount ) ;
1787+ }
1788+
17231789 let refund = RefundContents :: try_from ( (
17241790 payer_tlv_stream,
17251791 offer_tlv_stream,
@@ -1742,13 +1808,72 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
17421808 experimental_invoice_request_tlv_stream,
17431809 ) ) ?;
17441810
1811+ // Recurrence checks
1812+ if let Some ( offer_recurrence) = invoice_request. inner . offer . recurrence_fields ( ) {
1813+ // 1. MUST have basetime whenever offer has recurrence (optional or compulsory).
1814+ let invoice_basetime = match invoice_recurrence_basetime {
1815+ Some ( ts) => ts,
1816+ None => {
1817+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1818+ } ,
1819+ } ;
1820+
1821+ let offer_base = offer_recurrence. recurrence_base ;
1822+ let counter = invoice_request. recurrence_counter ( ) ;
1823+
1824+ // --------------------------------------------------------------------------
1825+ // Case A: Payer does NOT support recurrence (optional-mode fallback)
1826+ // No counter, no start, no cancel.
1827+ // We treat this invoice as a normal single-payment invoice.
1828+ // Basetime MUST still match presence rules, per the spec.
1829+ // --------------------------------------------------------------------------
1830+ if counter. is_none ( ) {
1831+ // Nothing else to check here.
1832+ // The invoice is not part of a recurrence sequence.
1833+ }
1834+
1835+ // Safe to unwrap because we just excluded None
1836+ let counter = counter. unwrap ( ) ;
1837+
1838+ // --------------------------------------------------------------------------
1839+ // Case B: First recurrence invoice (counter = 0)
1840+ // --------------------------------------------------------------------------
1841+ if counter == 0 {
1842+ match offer_base {
1843+ // Offer defined explicit basetime → MUST match exactly
1844+ Some ( base) => {
1845+ if invoice_basetime != base. basetime {
1846+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1847+ }
1848+ } ,
1849+
1850+ // Offer has no basetime → MUST match invoice.creation time
1851+ None => {
1852+ if invoice_basetime != fields. created_at . as_secs ( ) {
1853+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1854+ }
1855+ } ,
1856+ }
1857+ }
1858+ // --------------------------------------------------------------------------
1859+ // Case C: Successive recurrence invoices (counter > 0)
1860+ // --------------------------------------------------------------------------
1861+ else {
1862+ // Spec says SHOULD check equality with previous invoice basetime.
1863+ // But we cannot check that from here → MUST be done upstream.
1864+ // We leave a TODO so the reviewer sees this is intentional.
1865+ //
1866+ // TODO: Enforce SHOULD: invoice_basetime == previous_invoice_basetime
1867+ }
1868+ }
1869+
17451870 if let Some ( requested_amount_msats) = invoice_request. amount_msats ( ) {
17461871 if amount_msats != requested_amount_msats {
17471872 return Err ( Bolt12SemanticError :: InvalidAmount ) ;
17481873 }
17491874 }
17501875
1751- Ok ( InvoiceContents :: ForOffer { invoice_request, fields } )
1876+ Ok ( InvoiceContents :: ForOffer { invoice_request, fields, invoice_recurrence_basetime } )
17521877 }
17531878 }
17541879}
@@ -2019,6 +2144,7 @@ mod tests {
20192144 features: None ,
20202145 node_id: Some ( & recipient_pubkey( ) ) ,
20212146 message_paths: None ,
2147+ invoice_recurrence_basetime: None ,
20222148 } ,
20232149 SignatureTlvStreamRef { signature: Some ( & invoice. signature( ) ) } ,
20242150 ExperimentalOfferTlvStreamRef { experimental_foo: None } ,
@@ -2130,6 +2256,7 @@ mod tests {
21302256 features: None ,
21312257 node_id: Some ( & recipient_pubkey( ) ) ,
21322258 message_paths: None ,
2259+ invoice_recurrence_basetime: None ,
21332260 } ,
21342261 SignatureTlvStreamRef { signature: Some ( & invoice. signature( ) ) } ,
21352262 ExperimentalOfferTlvStreamRef { experimental_foo: None } ,
0 commit comments