@@ -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,28 @@ 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+ pub ( crate ) fn set_invoice_recurrence_basetime(
450+ & mut $self,
451+ basetime: u64
452+ ) {
453+ match & mut $self. invoice {
454+ InvoiceContents :: ForOffer { invoice_recurrence_basetime, .. } => {
455+ * 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
@@ -755,7 +779,40 @@ enum InvoiceContents {
755779 /// Contents for an [`Bolt12Invoice`] corresponding to an [`Offer`].
756780 ///
757781 /// [`Offer`]: crate::offers::offer::Offer
758- ForOffer { invoice_request : InvoiceRequestContents , fields : InvoiceFields } ,
782+ ForOffer {
783+ invoice_request : InvoiceRequestContents ,
784+ fields : InvoiceFields ,
785+ /// The recurrence anchor time (UNIX timestamp) for this invoice.
786+ ///
787+ /// Semantics:
788+ /// - If the offer specifies an explicit `recurrence_base`, this MUST equal it.
789+ /// - If the offer does not specify a base, this MUST be the creation time
790+ /// of the *first* invoice in the recurrence sequence.
791+ ///
792+ /// Requirements:
793+ /// - The payee must remember the basetime from the first invoice and reuse it
794+ /// for all subsequent invoices in the recurrence.
795+ /// - The payer must verify that the basetime in each invoice matches the
796+ /// basetime of previously paid periods, ensuring a stable schedule.
797+ ///
798+ /// Practical effect:
799+ /// This timestamp anchors the recurrence period calculation for the entire
800+ /// recurring-payment flow.
801+ ///
802+ /// Spec Commentary:
803+ /// The spec currently requires this field even when the offer already includes
804+ /// its own `recurrence_base`. Since invoices are always prsent alongside their
805+ /// offer, the basetime is already known. Duplicating it across offer → invoice
806+ /// adds redundant equivalence checks without providing new information.
807+ ///
808+ /// Possible simplification:
809+ /// - Include `invoice_recurrence_basetime` **only when** the offer did *not* define one.
810+ /// - Omit it otherwise and treat the offer as the single source of truth.
811+ ///
812+ /// This avoids redundant duplication and simplifies validation while preserving
813+ /// all necessary semantics.
814+ invoice_recurrence_basetime : Option < u64 > ,
815+ } ,
759816 /// Contents for an [`Bolt12Invoice`] corresponding to a [`Refund`].
760817 ///
761818 /// [`Refund`]: crate::offers::refund::Refund
@@ -1402,6 +1459,7 @@ impl InvoiceFields {
14021459 features,
14031460 node_id : Some ( & self . signing_pubkey ) ,
14041461 message_paths : None ,
1462+ invoice_recurrence_basetime : None ,
14051463 } ,
14061464 ExperimentalInvoiceTlvStreamRef {
14071465 #[ cfg( test) ]
@@ -1483,6 +1541,7 @@ tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, {
14831541 ( 172 , fallbacks: ( Vec <FallbackAddress >, WithoutLength ) ) ,
14841542 ( 174 , features: ( Bolt12InvoiceFeatures , WithoutLength ) ) ,
14851543 ( 176 , node_id: PublicKey ) ,
1544+ ( 177 , invoice_recurrence_basetime: ( u64 , HighZeroBytesDroppedBigSize ) ) ,
14861545 // Only present in `StaticInvoice`s.
14871546 ( 236 , message_paths: ( Vec <BlindedMessagePath >, WithoutLength ) ) ,
14881547} ) ;
@@ -1674,6 +1733,7 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
16741733 features,
16751734 node_id,
16761735 message_paths,
1736+ invoice_recurrence_basetime,
16771737 } ,
16781738 experimental_offer_tlv_stream,
16791739 experimental_invoice_request_tlv_stream,
@@ -1720,6 +1780,11 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
17201780 check_invoice_signing_pubkey ( & fields. signing_pubkey , & offer_tlv_stream) ?;
17211781
17221782 if offer_tlv_stream. issuer_id . is_none ( ) && offer_tlv_stream. paths . is_none ( ) {
1783+ // Recurrence should not be present in Refund.
1784+ if invoice_recurrence_basetime. is_some ( ) {
1785+ return Err ( Bolt12SemanticError :: InvalidAmount ) ;
1786+ }
1787+
17231788 let refund = RefundContents :: try_from ( (
17241789 payer_tlv_stream,
17251790 offer_tlv_stream,
@@ -1742,13 +1807,72 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
17421807 experimental_invoice_request_tlv_stream,
17431808 ) ) ?;
17441809
1810+ // Recurrence checks
1811+ if let Some ( offer_recurrence) = invoice_request. inner . offer . recurrence_fields ( ) {
1812+ // 1. MUST have basetime whenever offer has recurrence (optional or compulsory).
1813+ let invoice_basetime = match invoice_recurrence_basetime {
1814+ Some ( ts) => ts,
1815+ None => {
1816+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1817+ } ,
1818+ } ;
1819+
1820+ let offer_base = offer_recurrence. recurrence_base ;
1821+ let counter = invoice_request. recurrence_counter ( ) ;
1822+
1823+ // --------------------------------------------------------------------------
1824+ // Case A: Payer does NOT support recurrence (optional-mode fallback)
1825+ // No counter, no start, no cancel.
1826+ // We treat this invoice as a normal single-payment invoice.
1827+ // Basetime MUST still match presence rules, per the spec.
1828+ // --------------------------------------------------------------------------
1829+ if counter. is_none ( ) {
1830+ // Nothing else to check here.
1831+ // The invoice is not part of a recurrence sequence.
1832+ }
1833+
1834+ // Safe to unwrap because we just excluded None
1835+ let counter = counter. unwrap ( ) ;
1836+
1837+ // --------------------------------------------------------------------------
1838+ // Case B: First recurrence invoice (counter = 0)
1839+ // --------------------------------------------------------------------------
1840+ if counter == 0 {
1841+ match offer_base {
1842+ // Offer defined explicit basetime → MUST match exactly
1843+ Some ( base) => {
1844+ if invoice_basetime != base. basetime {
1845+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1846+ }
1847+ } ,
1848+
1849+ // Offer has no basetime → MUST match invoice.creation time
1850+ None => {
1851+ if invoice_basetime != fields. created_at . as_secs ( ) {
1852+ return Err ( Bolt12SemanticError :: InvalidMetadata ) ;
1853+ }
1854+ } ,
1855+ }
1856+ }
1857+ // --------------------------------------------------------------------------
1858+ // Case C: Successive recurrence invoices (counter > 0)
1859+ // --------------------------------------------------------------------------
1860+ else {
1861+ // Spec says SHOULD check equality with previous invoice basetime.
1862+ // But we cannot check that from here → MUST be done upstream.
1863+ // We leave a TODO so the reviewer sees this is intentional.
1864+ //
1865+ // TODO: Enforce SHOULD: invoice_basetime == previous_invoice_basetime
1866+ }
1867+ }
1868+
17451869 if let Some ( requested_amount_msats) = invoice_request. amount_msats ( ) {
17461870 if amount_msats != requested_amount_msats {
17471871 return Err ( Bolt12SemanticError :: InvalidAmount ) ;
17481872 }
17491873 }
17501874
1751- Ok ( InvoiceContents :: ForOffer { invoice_request, fields } )
1875+ Ok ( InvoiceContents :: ForOffer { invoice_request, fields, invoice_recurrence_basetime } )
17521876 }
17531877 }
17541878}
@@ -2019,6 +2143,7 @@ mod tests {
20192143 features: None ,
20202144 node_id: Some ( & recipient_pubkey( ) ) ,
20212145 message_paths: None ,
2146+ invoice_recurrence_basetime: None ,
20222147 } ,
20232148 SignatureTlvStreamRef { signature: Some ( & invoice. signature( ) ) } ,
20242149 ExperimentalOfferTlvStreamRef { experimental_foo: None } ,
@@ -2130,6 +2255,7 @@ mod tests {
21302255 features: None ,
21312256 node_id: Some ( & recipient_pubkey( ) ) ,
21322257 message_paths: None ,
2258+ invoice_recurrence_basetime: None ,
21332259 } ,
21342260 SignatureTlvStreamRef { signature: Some ( & invoice. signature( ) ) } ,
21352261 ExperimentalOfferTlvStreamRef { experimental_foo: None } ,
0 commit comments