Skip to content

Commit 3dfbadd

Browse files
committed
Introduce recurrence fields in Invoice (BOLT12 PoC)
This commit adds the recurrence-related TLVs to the `Invoice` encoding, allowing the payee to include `invoice_recurrence_basetime`. This field anchors the start time (UNIX timestamp) of the recurrence schedule and is required for validating period boundaries across successive invoices. Additional initialization logic, validation notes, and design considerations are documented inline within the commit. Spec reference: https://github.com/rustyrussell/bolts/blob/guilt/offers-recurrence/12-offer-encoding.md#invoices
1 parent 4877434 commit 3dfbadd

File tree

2 files changed

+137
-2
lines changed

2 files changed

+137
-2
lines changed

lightning/src/offers/invoice.rs

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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 },

lightning/src/offers/static_invoice.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ impl InvoiceContents {
474474
node_id: Some(&self.signing_pubkey),
475475
amount: None,
476476
payment_hash: None,
477+
invoice_recurrence_basetime: None,
477478
};
478479

479480
let experimental_invoice = ExperimentalInvoiceTlvStreamRef {
@@ -673,6 +674,7 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
673674
message_paths,
674675
payment_hash,
675676
amount,
677+
invoice_recurrence_basetime,
676678
},
677679
experimental_offer_tlv_stream,
678680
ExperimentalInvoiceTlvStream {
@@ -710,6 +712,11 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
710712
return Err(Bolt12SemanticError::UnexpectedChain);
711713
}
712714

715+
// Static invoices MUST NOT set recurrence.
716+
if invoice_recurrence_basetime.is_some() {
717+
return Err(Bolt12SemanticError::UnexpectedRecurrence);
718+
}
719+
713720
Ok(InvoiceContents {
714721
offer: OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?,
715722
payment_paths,
@@ -927,6 +934,7 @@ mod tests {
927934
features: None,
928935
node_id: Some(&signing_pubkey),
929936
message_paths: Some(&paths),
937+
invoice_recurrence_basetime: None,
930938
},
931939
SignatureTlvStreamRef { signature: Some(&invoice.signature()) },
932940
ExperimentalOfferTlvStreamRef { experimental_foo: None },

0 commit comments

Comments
 (0)