Skip to content

Commit 7deedcd

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 d97be25 commit 7deedcd

File tree

2 files changed

+128
-0
lines changed

2 files changed

+128
-0
lines changed

lightning/src/offers/invoice.rs

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

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)