From 01296b7abeac969c41f0c43e977bf5e75e43b05c Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Sep 2025 18:26:22 +0530 Subject: [PATCH 1/6] [feat] Add recurrence support to Offer fields Add draft BOLT 12 recurrence fields to `Offer`, including the public recurrence types and the TLV mapping for optional and compulsory recurrence. Update offer serialization and parsing to preserve `recurrence`, `recurrence_base`, `recurrence_paywindow`, and `recurrence_limit`. Reject recurrence TLVs on refund-like message types that do not support them, and update offer-related test fixtures for the expanded TLV stream. This introduces the offer-side recurrence data model and wire handling without yet implementing recurrence-aware invoice-request validation or payment flow behavior. Spec reference: BOLT 12 offers recurrence TLV fields draft. Co-Authored-By: OpenAI Codex --- lightning/src/offers/invoice.rs | 10 + lightning/src/offers/invoice_request.rs | 5 + lightning/src/offers/offer.rs | 445 ++++++++++++++++++++++++ lightning/src/offers/parse.rs | 4 + lightning/src/offers/refund.rs | 24 ++ lightning/src/offers/static_invoice.rs | 5 + 6 files changed, 493 insertions(+) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..425301a07ab 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -2014,6 +2014,11 @@ mod tests { issuer: None, quantity_max: None, issuer_id: Some(&recipient_pubkey()), + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }, InvoiceRequestTlvStreamRef { chain: None, @@ -2117,6 +2122,11 @@ mod tests { issuer: None, quantity_max: None, issuer_id: None, + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }, InvoiceRequestTlvStreamRef { chain: None, diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..4f72fd5ba27 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -1647,6 +1647,11 @@ mod tests { issuer: None, quantity_max: None, issuer_id: Some(&recipient_pubkey()), + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }, InvoiceRequestTlvStreamRef { chain: None, diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..5ea81069c7f 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -247,6 +247,7 @@ macro_rules! offer_explicit_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(signing_pubkey), + recurrence: None, #[cfg(test)] experimental_foo: None, }, @@ -301,6 +302,7 @@ macro_rules! offer_derived_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(node_id), + recurrence: None, #[cfg(test)] experimental_foo: None, }, @@ -632,10 +634,343 @@ pub(super) struct OfferContents { paths: Option>, supported_quantity: Quantity, issuer_signing_pubkey: Option, + recurrence: Option, #[cfg(test)] experimental_foo: Option, } +/// Represents the recurrence period as `(time_unit, count)`. +/// +/// The full duration of a recurrence period is defined by combining +/// [`time_unit`](Self::time_unit) with [`period`](Self::period). +/// +/// For example, `TimeUnit::Days` with `period = 7` represents a recurrence +/// every seven days. +// +// Implementation Note: +// The current spec design feels a bit non-optimal, as it requires both +// an enum and a struct to represent what is conceptually a single "period". +// Might revisit once the spec stabilizes. +// +// Spec Commentary: +// The naming around "period" and "time_unit" is slightly confusing. +// For example, `period means count_of_units`, while the actual recurrence +// "period" is `(period * time_unit)`. +// +// It may help the final spec to create clearer names for each variable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecurrencePeriod { + /// A recurrence period measured in whole seconds. + Seconds(u32), + /// A recurrence period measured in whole days. + Days(u32), + /// A recurrence period measured in whole calendar months. + Months(u32), +} + +impl RecurrencePeriod { + const SECONDS_PER_DAY: u64 = 86_400; + + fn start_time(self, basetime: u64, period_count: u32) -> Result { + match self { + RecurrencePeriod::Seconds(seconds) => { + let offset = u64::from(seconds).checked_mul(period_count.into()).ok_or(())?; + basetime.checked_add(offset).ok_or(()) + }, + + RecurrencePeriod::Days(days) => { + let days_since_epoch = basetime / Self::SECONDS_PER_DAY; + let seconds = basetime % Self::SECONDS_PER_DAY; + + let offset_days = u64::from(days).checked_mul(period_count.into()).ok_or(())?; + let start_day = days_since_epoch.checked_add(offset_days).ok_or(())?; + + start_day + .checked_mul(Self::SECONDS_PER_DAY) + .and_then(|day_seconds| day_seconds.checked_add(seconds)) + .ok_or(()) + }, + + RecurrencePeriod::Months(months) => { + let days_since_epoch = basetime / Self::SECONDS_PER_DAY; + let seconds = basetime % Self::SECONDS_PER_DAY; + + let (year, month, day) = civil_from_days(days_since_epoch as i128); + + let offset_months = i128::from(months).checked_mul(period_count.into()).ok_or(())?; + + let total_months = year + .checked_mul(12) + .and_then(|year_in_months| year_in_months.checked_add(month as i128 - 1)) + .and_then(|base_month| base_month.checked_add(offset_months)) + .ok_or(())?; + + let target_year = total_months.div_euclid(12); + let target_month = total_months.rem_euclid(12) + 1; + let target_day = day.min(days_in_month(target_year, target_month as u64)); + + let start_day = days_from_civil(target_year, target_month as u64, target_day); + + u64::try_from(start_day) + .ok() + .and_then(|start_day| start_day.checked_mul(Self::SECONDS_PER_DAY)) + .and_then(|day_seconds| day_seconds.checked_add(seconds)) + .ok_or(()) + }, + } + } +} + +fn is_leap_year(year: i128) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +fn days_in_month(year: i128, month: u64) -> u64 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + 2 => 28, + _ => panic!("invalid month"), + } +} + +fn days_from_civil(year: i128, month: u64, day: u64) -> i128 { + assert!((1..=12).contains(&month)); + assert!((1..=days_in_month(year, month)).contains(&day)); + + let mut y = year; + let m = month as i128; + let d = day as i128; + + if m <= 2 { + y -= 1; + } + + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = m + if m > 2 { -3 } else { 9 }; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + + era * 146_097 + doe - 719_468 +} + +fn civil_from_days(days: i128) -> (i128, u64, u64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + + if month <= 2 { + year += 1; + } + + (year, month as u64, day as u64) +} + +impl Writeable for RecurrencePeriod { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let (tag, period) = match self { + RecurrencePeriod::Seconds(p) => (0u8, p), + RecurrencePeriod::Days(p) => (1u8, p), + RecurrencePeriod::Months(p) => (2u8, p), + }; + + tag.write(writer)?; + HighZeroBytesDroppedBigSize(*period).write(writer) + } +} + +impl Readable for RecurrencePeriod { + fn read(r: &mut R) -> Result { + let time_unit_byte: u8 = Readable::read(r)?; + let period: HighZeroBytesDroppedBigSize = Readable::read(r)?; + + if period.0 == 0 { + return Err(DecodeError::InvalidValue); + } + + match time_unit_byte { + 0 => Ok(Self::Seconds(period.0)), + 1 => Ok(Self::Days(period.0)), + 2 => Ok(Self::Months(period.0)), + _ => Err(DecodeError::InvalidValue), + } + } +} + +/// Represents the base time from which recurrence periods are anchored. +/// +/// Example: +/// If an offer sets its basetime to Jan 1st, then the first recurrence +/// period is defined as starting on Jan 1st. +/// A payer starting on April 1st would begin at offset 3. +/// +/// If this field is absent from the offer, the protocol defines the start of +/// period 0 using the `invoice_created_at` timestamp of the first invoice in +/// the recurrence series. +// +// Spec Commentary: +// The presence of `proportional` here feels conceptually odd. +// It mixes two different ideas: +// 1. The *start anchor* of the recurrence schedule (`basetime`) +// 2. A *pricing policy* based on how far into the period the payer is +// +// It also raises questions: +// - Why is proportionality tied to basetime? +// - Why can’t proportional pricing exist without an explicit basetime? +// (It would make sense from the second period onward, where the +// schedule is already well-defined.) +// +// Might be worth revisiting the grouping of these fields in the final spec. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceBase { + /// If true, price is proportional to how much of the period has passed. + /// + /// Example: + /// For a 30-day period, paying 3 days after the start yields ~10% discount. + pub proportional: bool, + + /// Basetime expressed in UNIX seconds. + pub basetime: u64, +} + +impl Writeable for RecurrenceBase { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + (self.proportional as u8).write(writer)?; + HighZeroBytesDroppedBigSize(self.basetime).write(writer) + } +} + +impl Readable for RecurrenceBase { + fn read(r: &mut R) -> Result { + let proportional_byte: u8 = Readable::read(r)?; + let proportional = match proportional_byte { + 0 => false, + 1 => true, + _ => return Err(DecodeError::InvalidValue), + }; + + let basetime: HighZeroBytesDroppedBigSize = Readable::read(r)?; + + Ok(RecurrenceBase { proportional, basetime: basetime.0 }) + } +} + +/// Acceptance paywindow for a recurrence period. +/// Defines the time around the *start of a period* during which a payer's +/// payment SHOULD (not MUST) be accepted. +/// +/// If this field is absent, the default window is: +/// - the entire previous period, PLUS +/// - the entire current period being paid for. +// +// Spec Commentary: +// The use of SHOULD (instead of MUST) is unclear. +// What specific flexibility is intended here, and what behavior is expected +// from implementations outside the window? +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrencePaywindow { + /// Seconds *before* the period starts in which a payment SHOULD be allowed. + pub seconds_before: u32, + /// Seconds *after* the period starts in which a payment SHOULD be allowed. + pub seconds_after: u32, +} + +impl Writeable for RecurrencePaywindow { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + self.seconds_before.write(writer)?; + self.seconds_after.write(writer) + } +} + +impl Readable for RecurrencePaywindow { + fn read(r: &mut R) -> Result { + let before = Readable::read(r)?; + let after = Readable::read(r)?; + Ok(RecurrencePaywindow { seconds_before: before, seconds_after: after }) + } +} + +/// Maximum number of recurrence periods allowed for this offer. +/// +/// Counting always begins from the offer’s recurrence start: +/// - If `recurrence_base` is set, counting starts from that basetime. +/// - If it is not set, counting starts from the time the first invoice is created. +/// +/// After this limit is reached, further payments MUST NOT be accepted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceLimit(pub u32); + +impl Writeable for RecurrenceLimit { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + HighZeroBytesDroppedBigSize(self.0).write(writer) + } +} + +impl Readable for RecurrenceLimit { + fn read(r: &mut R) -> Result { + let value: HighZeroBytesDroppedBigSize = Readable::read(r)?; + if value.0 == 0 { + return Err(DecodeError::InvalidValue); + } + Ok(RecurrenceLimit(value.0)) + } +} + +/// Represents the recurrence-related fields in an Offer. +/// +/// The recurrence schedule itself is shared by both wire variants: +/// `offer_recurrence_optional` and `offer_recurrence_compulsory`. +/// `RecurrenceType` preserves which variant the offer used, while +/// `recurrence_paywindow` and `recurrence_limit` apply to either form. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceFields { + /// The recurrence schedule: period length and unit. + pub recurrence_period: RecurrencePeriod, + /// The allowed early/late window for paying a given period. + pub recurrence_paywindow: Option, + /// Maximum number of periods allowed for this Offer. + pub recurrence_limit: Option, +} + +/// Encodes the recurrence schedule and whether recurrence is optional or compulsory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recurrence { + /// Recurrence is required, optionally anchored by an explicit period-0 base. + Compulsory { + /// Explicit base time for period 0 when the offer defines one. + base: Option, + /// Fields shared by both recurrence encodings. + fields: RecurrenceFields, + }, + + /// Recurrence is supported but not required for payment. + Optional { + /// Fields shared by both recurrence encodings. + fields: RecurrenceFields, + }, +} + +/// Encodes whether a recurring offer is optional or compulsory for the payer. +/// +/// Compulsory recurrence may optionally define an explicit period-0 basetime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecurrenceType { + /// Recurrence is optional, so pre-recurrence payers may still attempt a + /// single payment. + Optional, + /// Recurrence is required for this offer. The optional basetime anchors + /// period 0 when the offer defines one explicitly. + Compulsory(Option), +} + macro_rules! offer_accessors { ($self: ident, $contents: expr) => { // TODO: Return a slice once ChainHash has constants. // - https://github.com/rust-bitcoin/rust-bitcoin/pull/1283 @@ -708,6 +1043,11 @@ macro_rules! offer_accessors { ($self: ident, $contents: expr) => { pub fn issuer_signing_pubkey(&$self) -> Option { $contents.issuer_signing_pubkey() } + + /// Returns the recurrence fields for the offer. + pub fn recurrence(&$self) -> Option<$crate::offers::offer::Recurrence> { + $contents.recurrence() + } } } impl Offer { @@ -993,6 +1333,10 @@ impl OfferContents { self.issuer_signing_pubkey } + pub fn recurrence(&self) -> Option { + self.recurrence + } + pub(super) fn verify_using_metadata( &self, bytes: &[u8], key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result<(OfferId, Option), ()> { @@ -1060,6 +1404,31 @@ impl OfferContents { } }; + let ( + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, + ) = match &self.recurrence { + None => (None, None, None, None, None), + + Some(Recurrence::Compulsory { base, fields }) => ( + Some(&fields.recurrence_period), + None, + base.as_ref(), + fields.recurrence_paywindow.as_ref(), + fields.recurrence_limit.as_ref(), + ), + Some(Recurrence::Optional { fields }) => ( + None, + Some(&fields.recurrence_period), + None, + fields.recurrence_paywindow.as_ref(), + fields.recurrence_limit.as_ref(), + ), + }; + let offer = OfferTlvStreamRef { chains: self.chains.as_ref(), metadata: self.metadata(), @@ -1072,6 +1441,11 @@ impl OfferContents { issuer: self.issuer.as_ref(), quantity_max: self.supported_quantity.to_tlv_record(), issuer_id: self.issuer_signing_pubkey.as_ref(), + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }; let experimental_offer = ExperimentalOfferTlvStreamRef { @@ -1226,6 +1600,38 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef<'a>, OFFER_TYPES, { (18, issuer: (String, WithoutLength)), (20, quantity_max: (u64, HighZeroBytesDroppedBigSize)), (OFFER_ISSUER_ID_TYPE, issuer_id: PublicKey), + + // --- Recurrence Fields (as described in BOLT12 recurrence) --- + // These comments are for implementation clarity and will be refined later. + + // (24) `recurrence_compulsory` + // Offer *requires* recurrence. + // Payer must understand and follow the recurrence schedule. + // Encodes the recurrence period (monthly, weekly, etc). + (24, recurrence_compulsory: RecurrencePeriod), + + // (25) `recurrence_optional` + // Offer *supports* recurrence but doesn't require it. + // Payers without recurrence support can treat it as a single-payment offer. + // Encodes the recurrence period. + (25, recurrence_optional: RecurrencePeriod), + + // (26) `recurrence_base` + // Start anchor ("base time") for the recurrence schedule. + // If absent: defaults to timestamp of the first invoice creation. + // Only meaningful when recurrence is compulsory. + (26, recurrence_base: RecurrenceBase), + + // (27) `recurrence_paywindow` + // Window around each period’s due time in which the payer SHOULD pay. + // If absent: default window is previous period + current period. + // Useful for handling early/late payments reliably. + (27, recurrence_paywindow: RecurrencePaywindow), + + // (29) `recurrence_limit` + // Maximum number of periods this offer can be paid for. + // Caps the total count of recurring payments. + (29, recurrence_limit: RecurrenceLimit), }); /// Valid type range for experimental offer TLV records. @@ -1295,6 +1701,11 @@ impl TryFrom for OfferContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -1342,6 +1753,33 @@ impl TryFrom for OfferContents { (issuer_id, paths) => (issuer_id, paths), }; + let recurrence = match (recurrence_compulsory, recurrence_optional, recurrence_base) { + (None, None, None) => { + if recurrence_paywindow.is_some() || recurrence_limit.is_some() { + return Err(Bolt12SemanticError::InvalidRecurrence); + } + None + }, + (Some(recurrence_period), None, base) => Some(Recurrence::Compulsory { + base, + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow, + recurrence_limit, + }, + }), + + (None, Some(recurrence_period), None) => Some(Recurrence::Optional { + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow, + recurrence_limit, + }, + }), + + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + }; + Ok(OfferContents { chains, metadata, @@ -1353,6 +1791,7 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, + recurrence, #[cfg(test)] experimental_foo, }) @@ -1430,6 +1869,7 @@ mod tests { assert_eq!(offer.supported_quantity(), Quantity::One); assert!(!offer.expects_quantity()); assert_eq!(offer.issuer_signing_pubkey(), Some(pubkey(42))); + assert_eq!(offer.recurrence(), None); assert_eq!( offer.as_tlv_stream(), @@ -1446,6 +1886,11 @@ mod tests { issuer: None, quantity_max: None, issuer_id: Some(&pubkey(42)), + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, ), diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs index df71e860d2d..e3b6e13c32e 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -233,6 +233,10 @@ pub enum Bolt12SemanticError { /// /// [`Refund`]: super::refund::Refund UnexpectedHumanReadableName, + /// Recurrence was not expected but present. + UnexpectedRecurrence, + /// Recurrence was present but contains invalid values. + InvalidRecurrence, } impl From for Bolt12ParseError { diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..ab10d478e4d 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -782,6 +782,11 @@ impl RefundContents { issuer: self.issuer.as_ref(), quantity_max: None, issuer_id: None, + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }; let features = { @@ -911,6 +916,11 @@ impl TryFrom for RefundContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, InvoiceRequestTlvStream { chain, @@ -972,6 +982,15 @@ impl TryFrom for RefundContents { return Err(Bolt12SemanticError::UnexpectedIssuerSigningPubkey); } + if recurrence_compulsory.is_some() + || recurrence_optional.is_some() + || recurrence_base.is_some() + || recurrence_paywindow.is_some() + || recurrence_limit.is_some() + { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + if offer_from_hrn.is_some() { // Only offers can be resolved using Human Readable Names return Err(Bolt12SemanticError::UnexpectedHumanReadableName); @@ -1101,6 +1120,11 @@ mod tests { issuer: None, quantity_max: None, issuer_id: None, + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }, InvoiceRequestTlvStreamRef { chain: None, diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index c8afb7cfc12..69913b4390f 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -918,6 +918,11 @@ mod tests { issuer: None, quantity_max: None, issuer_id: Some(&signing_pubkey), + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( From bc9cbb5b05bb3e78b8dc1a91999676ef21f5f7b7 Mon Sep 17 00:00:00 2001 From: shaavan Date: Tue, 5 May 2026 17:14:50 +0530 Subject: [PATCH 2/6] [test] Add recurrence offer and refund coverage Add targeted unit tests for the new offer recurrence TLV behavior. The tests cover compulsory and optional recurrence parsing, invalid recurrence field combinations on offers, direct `start_time` coverage for second/day/month schedules, and rejection of recurrence TLVs on refunds. This locks in the current wire-level recurrence behavior and the calendar-based `start_time` helper semantics, while guarding the message-type boundary where refunds must reject recurrence fields. Co-Authored-By: OpenAI Codex --- lightning/src/offers/offer.rs | 151 ++++++++++++++++++++++++++++++--- lightning/src/offers/refund.rs | 35 +++++++- 2 files changed, 172 insertions(+), 14 deletions(-) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 5ea81069c7f..695d107448c 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -247,7 +247,7 @@ macro_rules! offer_explicit_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(signing_pubkey), - recurrence: None, + offer_recurrence: None, #[cfg(test)] experimental_foo: None, }, @@ -302,7 +302,7 @@ macro_rules! offer_derived_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(node_id), - recurrence: None, + offer_recurrence: None, #[cfg(test)] experimental_foo: None, }, @@ -634,7 +634,7 @@ pub(super) struct OfferContents { paths: Option>, supported_quantity: Quantity, issuer_signing_pubkey: Option, - recurrence: Option, + offer_recurrence: Option, #[cfg(test)] experimental_foo: Option, } @@ -1045,8 +1045,8 @@ macro_rules! offer_accessors { ($self: ident, $contents: expr) => { } /// Returns the recurrence fields for the offer. - pub fn recurrence(&$self) -> Option<$crate::offers::offer::Recurrence> { - $contents.recurrence() + pub fn offer_recurrence(&$self) -> Option<$crate::offers::offer::Recurrence> { + $contents.offer_recurrence() } } } @@ -1333,8 +1333,8 @@ impl OfferContents { self.issuer_signing_pubkey } - pub fn recurrence(&self) -> Option { - self.recurrence + pub fn offer_recurrence(&self) -> Option { + self.offer_recurrence } pub(super) fn verify_using_metadata( @@ -1410,7 +1410,7 @@ impl OfferContents { recurrence_base, recurrence_paywindow, recurrence_limit, - ) = match &self.recurrence { + ) = match &self.offer_recurrence { None => (None, None, None, None, None), Some(Recurrence::Compulsory { base, fields }) => ( @@ -1753,7 +1753,7 @@ impl TryFrom for OfferContents { (issuer_id, paths) => (issuer_id, paths), }; - let recurrence = match (recurrence_compulsory, recurrence_optional, recurrence_base) { + let offer_recurrence = match (recurrence_compulsory, recurrence_optional, recurrence_base) { (None, None, None) => { if recurrence_paywindow.is_some() || recurrence_limit.is_some() { return Err(Bolt12SemanticError::InvalidRecurrence); @@ -1791,7 +1791,7 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, - recurrence, + offer_recurrence, #[cfg(test)] experimental_foo, }) @@ -1825,8 +1825,9 @@ mod tests { #[cfg(c_bindings)] use super::OfferWithExplicitMetadataBuilder as OfferBuilder; use super::{ - Amount, ExperimentalOfferTlvStreamRef, Offer, OfferTlvStreamRef, Quantity, - EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + days_from_civil, Amount, ExperimentalOfferTlvStreamRef, FullOfferTlvStreamRef, Offer, + OfferTlvStreamRef, Quantity, Recurrence, RecurrenceBase, RecurrenceFields, RecurrenceLimit, + RecurrencePaywindow, RecurrencePeriod, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::blinded_path::message::BlindedMessagePath; @@ -1847,6 +1848,27 @@ mod tests { use core::num::NonZeroU64; use core::time::Duration; + trait ToBytes { + fn to_bytes(&self) -> Vec; + } + + impl<'a> ToBytes for FullOfferTlvStreamRef<'a> { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + + fn unix_time(year: i128, month: u64, day: u64, hour: u64, minute: u64, second: u64) -> u64 { + let days_since_epoch = days_from_civil(year, month, day); + u64::try_from(days_since_epoch) + .unwrap() + .checked_mul(RecurrencePeriod::SECONDS_PER_DAY) + .and_then(|days| days.checked_add(hour * 3600 + minute * 60 + second)) + .unwrap() + } + #[test] fn builds_offer_with_defaults() { let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); @@ -1869,7 +1891,7 @@ mod tests { assert_eq!(offer.supported_quantity(), Quantity::One); assert!(!offer.expects_quantity()); assert_eq!(offer.issuer_signing_pubkey(), Some(pubkey(42))); - assert_eq!(offer.recurrence(), None); + assert_eq!(offer.offer_recurrence(), None); assert_eq!( offer.as_tlv_stream(), @@ -1901,6 +1923,109 @@ mod tests { } } + #[test] + fn parses_offer_with_compulsory_recurrence() { + let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let recurrence_period = RecurrencePeriod::Months(3); + let recurrence_base = RecurrenceBase { proportional: true, basetime: 123_456 }; + let recurrence_paywindow = + RecurrencePaywindow { seconds_before: 3600, seconds_after: 7200 }; + let recurrence_limit = RecurrenceLimit(24); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.0.recurrence_base = Some(&recurrence_base); + tlv_stream.0.recurrence_paywindow = Some(&recurrence_paywindow); + tlv_stream.0.recurrence_limit = Some(&recurrence_limit); + + match Offer::try_from(tlv_stream.to_bytes()) { + Ok(parsed_offer) => { + assert_eq!( + parsed_offer.offer_recurrence(), + Some(Recurrence::Compulsory { + base: Some(recurrence_base), + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow: Some(recurrence_paywindow), + recurrence_limit: Some(recurrence_limit), + }, + }), + ); + }, + Err(e) => panic!("error parsing offer: {:?}", e), + } + } + + #[test] + fn parses_offer_with_optional_recurrence() { + let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let recurrence_period = RecurrencePeriod::Days(14); + let recurrence_paywindow = + RecurrencePaywindow { seconds_before: 1800, seconds_after: 5400 }; + let recurrence_limit = RecurrenceLimit(12); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + tlv_stream.0.recurrence_paywindow = Some(&recurrence_paywindow); + tlv_stream.0.recurrence_limit = Some(&recurrence_limit); + + match Offer::try_from(tlv_stream.to_bytes()) { + Ok(parsed_offer) => { + assert_eq!( + parsed_offer.offer_recurrence(), + Some(Recurrence::Optional { + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow: Some(recurrence_paywindow), + recurrence_limit: Some(recurrence_limit), + }, + }), + ); + }, + Err(e) => panic!("error parsing offer: {:?}", e), + } + } + + #[test] + fn fails_parsing_offer_with_invalid_recurrence_fields() { + let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let recurrence_compulsory = RecurrencePeriod::Months(1); + let recurrence_optional = RecurrencePeriod::Days(7); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_compulsory); + tlv_stream.0.recurrence_optional = Some(&recurrence_optional); + + match Offer::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ); + }, + } + } + + #[test] + fn calculates_recurrence_start_time_for_seconds() { + let start_time = RecurrencePeriod::Seconds(600).start_time(1_234, 3); + assert_eq!(start_time, Ok(3_034)); + } + + #[test] + fn calculates_recurrence_start_time_for_days() { + let basetime = unix_time(2024, 1, 15, 1, 2, 3); + let start_time = RecurrencePeriod::Days(2).start_time(basetime, 3); + + assert_eq!(start_time, Ok(unix_time(2024, 1, 21, 1, 2, 3))); + } + + #[test] + fn calculates_recurrence_start_time_for_months() { + let basetime = unix_time(2024, 1, 15, 1, 2, 3); + let start_time = RecurrencePeriod::Months(1).start_time(basetime, 2); + + assert_eq!(start_time, Ok(unix_time(2024, 3, 15, 1, 2, 3))); + } + #[test] fn builds_offer_with_chains() { let mainnet = ChainHash::using_genesis_block(Network::Bitcoin); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index ab10d478e4d..b60980ad293 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -1061,7 +1061,10 @@ mod tests { EXPERIMENTAL_INVOICE_REQUEST_TYPES, INVOICE_REQUEST_TYPES, }; use crate::offers::nonce::Nonce; - use crate::offers::offer::{ExperimentalOfferTlvStreamRef, OfferTlvStreamRef}; + use crate::offers::offer::{ + ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, RecurrenceBase, RecurrenceLimit, + RecurrencePaywindow, RecurrencePeriod, + }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::payer::PayerTlvStreamRef; use crate::offers::test_utils::*; @@ -1745,6 +1748,36 @@ mod tests { } } + #[test] + fn fails_parsing_refund_with_unexpected_recurrence_fields() { + let refund = + RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap().build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let recurrence_period = RecurrencePeriod::Months(1); + let recurrence_base = RecurrenceBase { proportional: false, basetime: 123_456 }; + let recurrence_paywindow = + RecurrencePaywindow { seconds_before: 3600, seconds_after: 3600 }; + let recurrence_limit = RecurrenceLimit(4); + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.1.recurrence_base = Some(&recurrence_base); + tlv_stream.1.recurrence_paywindow = Some(&recurrence_paywindow); + tlv_stream.1.recurrence_limit = Some(&recurrence_limit); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ); + }, + } + } + #[test] fn parses_refund_with_unknown_tlv_records() { const UNKNOWN_ODD_TYPE: u64 = INVOICE_REQUEST_TYPES.end - 1; From e9f03da3479e70b163dcc55baf8a07af3f31a41f Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Sep 2025 18:39:08 +0530 Subject: [PATCH 3/6] [feat] Add recurrence support to InvoiceRequest Add parsing and serialization for recurring invoice request fields, including the recurrence counter, basetime start offset, cancellation flag, and the provisional request-side recurrence token. Validate that invoice requests use the recurrence form required by the offer and thread the new fields through the shared invoice-request and refund TLV plumbing plus the payment context copies used when handling offer payments. Spec reference: https://github.com/rustyrussell/bolts/blob/guilt/offers-recurrence/12-offer-encoding.md#tlv-fields-for-invoice_request Co-Authored-By: OpenAI Codex --- fuzz/src/invoice_request_deser.rs | 1 + lightning/src/ln/offers_tests.rs | 7 + lightning/src/offers/invoice.rs | 8 + lightning/src/offers/invoice_request.rs | 266 ++++++++++++++++++++++-- lightning/src/offers/offer.rs | 3 + lightning/src/offers/refund.rs | 17 ++ 6 files changed, 285 insertions(+), 17 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..2140fc1822d 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -98,6 +98,7 @@ fn build_response( .payer_note() .map(|s| UntrustedString(s.to_string())), human_readable_name: None, + invoice_request_recurrence: None, } }; diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..fc48daeee68 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -727,6 +727,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -885,6 +886,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1006,6 +1008,7 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); @@ -1274,6 +1277,7 @@ fn creates_and_pays_for_offer_with_retry() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1339,6 +1343,7 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); @@ -1436,6 +1441,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -2647,6 +2653,7 @@ fn creates_and_pays_for_phantom_offer() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 425301a07ab..871c87412d5 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -2029,6 +2029,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( @@ -2137,6 +2141,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 4f72fd5ba27..dc14cd37a62 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -78,7 +78,7 @@ use crate::offers::merkle::{ use crate::offers::nonce::Nonce; use crate::offers::offer::{ Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents, - OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + OfferId, OfferTlvStream, OfferTlvStreamRef, Recurrence, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef}; @@ -183,10 +183,11 @@ macro_rules! invoice_request_builder_methods { ( #[cfg_attr(c_bindings, allow(dead_code))] fn create_contents(offer: &Offer, metadata: Metadata) -> InvoiceRequestContentsWithoutPayerSigningPubkey { let offer = offer.contents.clone(); + InvoiceRequestContentsWithoutPayerSigningPubkey { payer: PayerContents(metadata), offer, chain: None, amount_msats: None, features: InvoiceRequestFeatures::empty(), quantity: None, payer_note: None, - offer_from_hrn: None, + offer_from_hrn: None, invoice_request_recurrence: None, #[cfg(test)] experimental_bar: None, } @@ -255,6 +256,18 @@ macro_rules! invoice_request_builder_methods { ( $return_value } + /// Sets all recurrence-related fields for this invoice request in one call. + /// + /// `invoice_request_recurrence` must match the offer's recurrence configuration. Use + /// [`InvoiceRequestRecurrence::WithOfferBasetime`] for offers with an explicit recurrence + /// basetime and [`InvoiceRequestRecurrence::WithoutOfferBasetime`] otherwise. + /// + /// Successive calls override the previous setting. + pub fn set_invoice_request_recurrence($($self_mut)* $self: $self_type, invoice_request_recurrence: InvoiceRequestRecurrence) -> $return_type { + $self.invoice_request.invoice_request_recurrence = Some(invoice_request_recurrence); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -278,6 +291,25 @@ macro_rules! invoice_request_builder_methods { ( return Err(Bolt12SemanticError::MissingAmount); } + // Ensure the invoice request recurrence form matches the offer's recurrence configuration. + match ($self.offer.offer_recurrence(), &$self.invoice_request.invoice_request_recurrence) { + (None, None) => (), + ( + Some(Recurrence::Compulsory { base: Some(_), .. }), + Some(InvoiceRequestRecurrence::WithOfferBasetime { .. }) + ) => (), + ( + Some(Recurrence::Compulsory { base: None, .. }), + Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }) + ) => (), + ( + Some(Recurrence::Optional { .. }), + None | Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }) + ) => (), + + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + } + $self.invoice_request.offer.check_quantity($self.invoice_request.quantity)?; $self.invoice_request.offer.check_amount_msats_for_quantity( $self.invoice_request.amount_msats, $self.invoice_request.quantity @@ -675,6 +707,94 @@ pub(super) struct InvoiceRequestContents { payer_signing_pubkey: PublicKey, } +/// Recurrence-specific fields carried by an [`InvoiceRequest`]. +/// +/// These fields identify which recurring request this is and, when present, echo opaque +/// payee-authored state from previous invoice into next recurring request. +/// +/// `counter` always identifies payer's own recurring request number. When offer has explicit +/// basetime, schedule period index is: +/// period_index = start + counter +/// +/// `token`, when present, is exact byte string chosen by payee in previous invoice and echoed +/// back unchanged by payer. Payer must not interpret or modify it. +/// +/// `cancel` marks request as recurrence cancellation and is invalid on first recurring request +/// (`counter = 0`). +/// +/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InvoiceRequestRecurrence { + /// Recurrence fields for offers with an explicit recurrence basetime. + WithOfferBasetime(WithOfferBasetimeRecurrence), + /// Recurrence fields for offers without an explicit recurrence basetime. + WithoutOfferBasetime(WithoutOfferBasetimeRecurrence), +} + +/// Recurrence fields for invoice requests whose offer recurrence is anchored to an explicit +/// basetime. +/// +/// `counter` identifies which recurring request this is in the payer's sequence. `start` +/// identifies the schedule period where the payer began recurring payments. `token`, when +/// present, is the opaque recurrence token echoed from the previous invoice. `cancel`, when +/// present, marks the request as a recurrence cancellation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WithOfferBasetimeRecurrence { + counter: u32, + start: u32, + token: Option>, + cancel: Option<()>, +} + +/// Recurrence fields for invoice requests whose offer recurrence is anchored to the first +/// successful payment instead of an explicit basetime. +/// +/// `counter` identifies which recurring request this is in the payer's sequence. `token`, when +/// present, is the opaque recurrence token echoed from the previous invoice. `cancel`, when +/// present, marks the request as a recurrence cancellation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WithoutOfferBasetimeRecurrence { + counter: u32, + token: Option>, + cancel: Option<()>, +} + +impl InvoiceRequestRecurrence { + pub(crate) fn new( + recurrence_counter: Option, recurrence_start: Option, + recurrence_token: Option>, recurrence_cancel: Option<()>, + ) -> Result, ()> { + match (recurrence_counter, recurrence_start, recurrence_token, recurrence_cancel) { + (None, None, None, None) => Ok(None), + // Primary invoice requests (counter 0) cannot include token or cancellation state. + (Some(0), _, Some(_), _) | (Some(0), _, _, Some(_)) => Err(()), + (Some(counter), Some(start), token, cancel) => { + let inner = WithOfferBasetimeRecurrence { counter, start, token, cancel }; + Ok(Some(Self::WithOfferBasetime(inner))) + }, + (Some(counter), None, token, cancel) => { + let inner = WithoutOfferBasetimeRecurrence { counter, token, cancel }; + Ok(Some(Self::WithoutOfferBasetime(inner))) + }, + _ => Err(()), + } + } + + pub(crate) fn fields(&self) -> (Option, Option, Option<&Vec>, Option<&()>) { + match self { + Self::WithOfferBasetime(inner) => ( + Some(inner.counter), + Some(inner.start), + inner.token.as_ref(), + inner.cancel.as_ref(), + ), + Self::WithoutOfferBasetime(inner) => { + (Some(inner.counter), None, inner.token.as_ref(), inner.cancel.as_ref()) + }, + } + } +} + #[derive(Clone, Debug)] #[cfg_attr(test, derive(PartialEq))] pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { @@ -686,6 +806,22 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, + /// Recurrence fields for this invoice request. + /// + /// `counter` identifies which recurring invoice request this is in the payer's own sequence. + /// It does not necessarily equal the schedule period index. + /// + /// When `start` is present, the schedule period index is: + /// period_index = recurrence_start + recurrence_counter + /// + /// `start` is only meaningful when the offer defines an explicit recurrence basetime. For + /// example, a monthly schedule anchored on January 1st would use `start = 3` to begin on April + /// 1st. + /// + /// `cancel` marks this as a recurrence cancellation request. It is invalid on the first + /// recurring request (`counter = 0`). `token` is a speculative opaque echo field from the + /// previous invoice and is likewise invalid on the first recurring request. + invoice_request_recurrence: Option, #[cfg(test)] experimental_bar: Option, } @@ -736,6 +872,11 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { $contents.payer_signing_pubkey() } + /// Recurrence fields copied from this invoice request, if it belongs to a recurring offer. + pub fn invoice_request_recurrence(&$self) -> &Option { + $contents.invoice_request_recurrence() + } + /// A payer-provided note which will be seen by the recipient and reflected back in the invoice /// response. pub fn payer_note(&$self) -> Option> { @@ -1050,6 +1191,7 @@ macro_rules! fields_accessor { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, + invoice_request_recurrence, .. }, } = &$inner; @@ -1063,6 +1205,7 @@ macro_rules! fields_accessor { // down to the nearest valid UTF-8 code point boundary. .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), human_readable_name: $self.offer_from_hrn().clone(), + invoice_request_recurrence: invoice_request_recurrence.clone(), } } }; @@ -1171,6 +1314,10 @@ impl InvoiceRequestContents { self.payer_signing_pubkey } + pub(super) fn invoice_request_recurrence(&self) -> &Option { + &self.inner.invoice_request_recurrence + } + pub(super) fn payer_note(&self) -> Option> { self.inner.payer_note.as_ref().map(|payer_note| PrintableString(payer_note.as_str())) } @@ -1213,6 +1360,12 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { } }; + let (recurrence_counter, recurrence_start, recurrence_token, recurrence_cancel) = + match self.invoice_request_recurrence.as_ref() { + None => (None, None, None, None), + Some(rec) => rec.fields(), + }; + let invoice_request = InvoiceRequestTlvStreamRef { chain: self.chain.as_ref(), amount: self.amount_msats, @@ -1222,6 +1375,10 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { payer_note: self.payer_note.as_ref(), offer_from_hrn: self.offer_from_hrn.as_ref(), paths: None, + recurrence_counter, + recurrence_start, + recurrence_cancel, + recurrence_token, }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { @@ -1282,6 +1439,12 @@ tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef<'a>, INVOICE_REQ // Only used for Refund since the onion message of an InvoiceRequest has a reply path. (90, paths: (Vec, WithoutLength)), (91, offer_from_hrn: HumanReadableName), + (92, recurrence_counter: (u32, HighZeroBytesDroppedBigSize)), + (93, recurrence_start: (u32, HighZeroBytesDroppedBigSize)), + (94, recurrence_cancel: ()), + // Speculative recurrence-token TLV pending upstream BOLT12 assignment. Type 95 is provisional + // and may change when the proposal is merged into the spec. + (95, recurrence_token: (Vec, WithoutLength)), }); /// Valid type range for experimental invoice_request TLV records. @@ -1434,6 +1597,10 @@ impl TryFrom for InvoiceRequestContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, + recurrence_token, }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { @@ -1470,21 +1637,48 @@ impl TryFrom for InvoiceRequestContents { return Err(Bolt12SemanticError::UnexpectedPaths); } - Ok(InvoiceRequestContents { - inner: InvoiceRequestContentsWithoutPayerSigningPubkey { - payer, - offer, - chain, - amount_msats: amount, - features, - quantity, - payer_note, - offer_from_hrn, - #[cfg(test)] - experimental_bar, - }, - payer_signing_pubkey, - }) + let invoice_request_recurrence = InvoiceRequestRecurrence::new( + recurrence_counter, + recurrence_start, + recurrence_token, + recurrence_cancel, + ) + .map_err(|_| Bolt12SemanticError::InvalidRecurrence)?; + + // Recurrence sanity check + match (offer.offer_recurrence(), &invoice_request_recurrence) { + (None, None) => (), + ( + Some(Recurrence::Compulsory { base: Some(_), .. }), + Some(InvoiceRequestRecurrence::WithOfferBasetime { .. }), + ) => (), + ( + Some(Recurrence::Compulsory { base: None, .. }), + Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }), + ) => (), + ( + Some(Recurrence::Optional { .. }), + None | Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }), + ) => (), + + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + } + + let inner = InvoiceRequestContentsWithoutPayerSigningPubkey { + payer, + offer, + chain, + amount_msats: amount, + features, + quantity, + payer_note, + offer_from_hrn, + invoice_request_recurrence, + #[cfg(test)] + experimental_bar, + }; + + Ok(InvoiceRequestContents { inner, payer_signing_pubkey }) } } @@ -1505,6 +1699,15 @@ pub struct InvoiceRequestFields { /// The Human Readable Name which the sender indicated they were paying to. pub human_readable_name: Option, + + /// Recurrence fields copied from the invoice request when the request belongs to a recurring + /// offer. + /// + /// `counter` identifies the payer's recurring request number, `start` identifies the schedule + /// offset when the offer has an explicit basetime, `cancel` distinguishes cancellation + /// requests from normal recurring requests, and the speculative `token` echoes opaque bytes + /// from the previous invoice. + pub invoice_request_recurrence: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1517,11 +1720,22 @@ pub const PAYER_NOTE_LIMIT: usize = 8; impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let (recurrence_counter, recurrence_start, recurrence_token, recurrence_cancel) = + match self.invoice_request_recurrence.as_ref() { + None => (None, None, None, None), + Some(rec) => rec.fields(), + }; + write_tlv_fields!(writer, { (0, self.payer_signing_pubkey, required), (1, self.human_readable_name, option), (2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option), (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), + (6, recurrence_counter.map(HighZeroBytesDroppedBigSize), option), + (8, recurrence_start.map(HighZeroBytesDroppedBigSize), option), + (10, recurrence_cancel, option), + // Speculative request-side recurrence token, distinct from the future invoice-side token. + (12, recurrence_token.map(|token| WithoutLength(token)), option), }); Ok(()) } @@ -1534,13 +1748,26 @@ impl Readable for InvoiceRequestFields { (1, human_readable_name, option), (2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))), (4, payer_note_truncated, (option, encoding: (String, WithoutLength))), + (6, recurrence_counter, (option, encoding: (u32, HighZeroBytesDroppedBigSize))), + (8, recurrence_start, (option, encoding: (u32, HighZeroBytesDroppedBigSize))), + (10, recurrence_cancel, option), + (12, recurrence_token, (option, encoding: (Vec, WithoutLength))), }); + let invoice_request_recurrence = InvoiceRequestRecurrence::new( + recurrence_counter, + recurrence_start, + recurrence_token, + recurrence_cancel, + ) + .map_err(|_| DecodeError::InvalidValue)?; + Ok(InvoiceRequestFields { payer_signing_pubkey: payer_signing_pubkey.0.unwrap(), quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + invoice_request_recurrence, }) } } @@ -1662,6 +1889,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -3123,6 +3354,7 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + invoice_request_recurrence: None, } ); diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 695d107448c..45c8ccbf906 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -904,6 +904,9 @@ impl Readable for RecurrencePaywindow { /// - If `recurrence_base` is set, counting starts from that basetime. /// - If it is not set, counting starts from the time the first invoice is created. /// +/// This value is a count, not a zero-based index. For example, `RecurrenceLimit(5)` permits +/// period indices `0..=4` and rejects period index `5`. +/// /// After this limit is reached, further payments MUST NOT be accepted. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RecurrenceLimit(pub u32); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index b60980ad293..0063c560105 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -806,6 +806,10 @@ impl RefundContents { payer_note: self.payer_note.as_ref(), paths: self.paths.as_ref(), offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }; let experimental_offer = ExperimentalOfferTlvStreamRef { @@ -931,6 +935,10 @@ impl TryFrom for RefundContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, + recurrence_token: _, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -996,6 +1004,11 @@ impl TryFrom for RefundContents { return Err(Bolt12SemanticError::UnexpectedHumanReadableName); } + if recurrence_counter.is_some() || recurrence_start.is_some() || recurrence_cancel.is_some() + { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + let amount_msats = match amount { None => return Err(Bolt12SemanticError::MissingAmount), Some(amount_msats) if amount_msats > MAX_VALUE_MSAT => { @@ -1138,6 +1151,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, From 9fd1f2346a700abd01becee48d1c5e7373f45075 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 8 May 2026 20:37:25 +0530 Subject: [PATCH 4/6] [test] Cover recurring invoice request validation Add focused invoice request tests for the new recurrence TLVs. The new coverage exercises both supported recurrence encodings and the invalid combinations that should be rejected during parsing. Also extend refund parsing coverage to assert that invoice-request recurrence TLVs remain rejected on refund messages, which protects the shared TLV plumbing added by the recurrence feature commit. Co-Authored-By: OpenAI Codex --- lightning/src/offers/invoice_request.rs | 165 +++++++++++++++++++++++- lightning/src/offers/refund.rs | 15 +++ 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index dc14cd37a62..edc1758b73c 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -1776,8 +1776,9 @@ impl Readable for InvoiceRequestFields { mod tests { use super::{ ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequest, InvoiceRequestFields, - InvoiceRequestTlvStreamRef, UnsignedInvoiceRequest, EXPERIMENTAL_INVOICE_REQUEST_TYPES, - INVOICE_REQUEST_TYPES, PAYER_NOTE_LIMIT, SIGNATURE_TAG, + InvoiceRequestRecurrence, InvoiceRequestTlvStreamRef, PartialInvoiceRequestTlvStreamRef, + UnsignedInvoiceRequest, WithOfferBasetimeRecurrence, WithoutOfferBasetimeRecurrence, + EXPERIMENTAL_INVOICE_REQUEST_TYPES, INVOICE_REQUEST_TYPES, PAYER_NOTE_LIMIT, SIGNATURE_TAG, }; use crate::ln::channelmanager::PaymentId; @@ -1792,7 +1793,8 @@ mod tests { #[cfg(c_bindings)] use crate::offers::offer::OfferWithExplicitMetadataBuilder as OfferBuilder; use crate::offers::offer::{ - Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity, + Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, Offer, OfferTlvStreamRef, Quantity, + RecurrenceBase, RecurrencePeriod, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::payer::PayerTlvStreamRef; @@ -1807,6 +1809,26 @@ mod tests { #[cfg(feature = "std")] use core::time::Duration; + trait ToBytes { + fn to_bytes(&self) -> Vec; + } + + impl<'a> ToBytes for (OfferTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef) { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + + impl<'a> ToBytes for PartialInvoiceRequestTlvStreamRef<'a> { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + #[test] fn builds_invoice_request_with_defaults() { let expanded_key = ExpandedKey::new([42; 32]); @@ -3156,6 +3178,143 @@ mod tests { } } + #[test] + fn parses_invoice_request_with_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_period = RecurrencePeriod::Months(3); + let recurrence_base = RecurrenceBase { proportional: true, basetime: 123_456 }; + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.0.recurrence_base = Some(&recurrence_base); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let recurrence = InvoiceRequestRecurrence::WithOfferBasetime(WithOfferBasetimeRecurrence { + counter: 3, + start: 2, + token: Some(vec![1, 2, 3]), + cancel: Some(()), + }); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .set_invoice_request_recurrence(recurrence.clone()) + .build_and_sign() + .unwrap(); + + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + + match InvoiceRequest::try_from(buffer) { + Ok(invoice_request) => { + assert_eq!(invoice_request.invoice_request_recurrence(), &Some(recurrence)); + }, + Err(e) => panic!("error parsing invoice_request: {:?}", e), + } + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let recurrence = + InvoiceRequestRecurrence::WithoutOfferBasetime(WithoutOfferBasetimeRecurrence { + counter: 2, + token: Some(vec![4, 5, 6]), + cancel: Some(()), + }); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .set_invoice_request_recurrence(recurrence.clone()) + .build_and_sign() + .unwrap(); + + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + + match InvoiceRequest::try_from(buffer) { + Ok(invoice_request) => { + assert_eq!(invoice_request.invoice_request_recurrence(), &Some(recurrence)); + }, + Err(e) => panic!("error parsing invoice_request: {:?}", e), + } + } + + #[test] + fn fails_parsing_invoice_request_with_invalid_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_period = RecurrencePeriod::Months(1); + let recurrence_base = RecurrenceBase { proportional: false, basetime: 123_456 }; + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.0.recurrence_base = Some(&recurrence_base); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .set_invoice_request_recurrence(InvoiceRequestRecurrence::WithOfferBasetime( + WithOfferBasetimeRecurrence { counter: 1, start: 1, token: None, cancel: None }, + )) + .build_and_sign() + .unwrap(); + let recurrence_token = vec![42; 3]; + let mut tlv_stream = invoice_request.contents.as_tlv_stream(); + tlv_stream.2.recurrence_counter = Some(0); + tlv_stream.2.recurrence_start = Some(1); + tlv_stream.2.recurrence_token = Some(&recurrence_token); + + match InvoiceRequest::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .build_and_sign() + .unwrap(); + let mut tlv_stream = invoice_request.contents.as_tlv_stream(); + tlv_stream.2.recurrence_counter = Some(1); + tlv_stream.2.recurrence_start = Some(0); + + match InvoiceRequest::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + } + #[test] fn parses_invoice_request_with_experimental_tlv_records() { let expanded_key = ExpandedKey::new([42; 32]); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index 0063c560105..cb0c995f5a1 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -1793,6 +1793,21 @@ mod tests { ); }, } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.2.recurrence_counter = Some(1); + tlv_stream.2.recurrence_start = Some(2); + tlv_stream.2.recurrence_cancel = Some(&()); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ); + }, + } } #[test] From 1dfc5b9f8b6347b1589e595cd7613a0a2a31c9cb Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 8 May 2026 20:52:16 +0530 Subject: [PATCH 5/6] [feat] Introduce invoice recurrence Add invoice-side recurrence fields so recurring offers can carry a resolved basetime and opaque token forward to the next invoice request. This wires recurrence state through invoice creation, serialization, and static-invoice validation. The change is needed to let invoice handling participate in the recurrence flow introduced on offers and invoice requests, while keeping refunds and static invoices strict about rejecting unexpected recurrence data. Document the new public recurrence API surfaced by this work so the commit builds cleanly under deny(missing_docs). --- lightning/src/offers/invoice.rs | 63 +++++++++++++++- lightning/src/offers/invoice_request.rs | 99 +++++++++++++++++++++++-- lightning/src/offers/static_invoice.rs | 22 +++++- 3 files changed, 174 insertions(+), 10 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 871c87412d5..e835d0552db 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -243,9 +243,11 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn for_offer( invoice_request: &'a InvoiceRequest, payment_paths: Vec, - created_at: Duration, payment_hash: PaymentHash, signing_pubkey: PublicKey, + created_at: Duration, recurrence_basetime: Option, payment_hash: PaymentHash, + signing_pubkey: PublicKey, ) -> Result { let amount_msats = Self::amount_msats(invoice_request)?; + let invoice_recurrence = Self::recurrence_fields(invoice_request, recurrence_basetime)?; let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), fields: Self::fields( @@ -254,6 +256,7 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + invoice_recurrence, ), }; @@ -274,6 +277,7 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + None, ), }; @@ -315,9 +319,11 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn for_offer_using_keys( invoice_request: &'a InvoiceRequest, payment_paths: Vec, - created_at: Duration, payment_hash: PaymentHash, keys: Keypair, + created_at: Duration, recurrence_basetime: Option, payment_hash: PaymentHash, + keys: Keypair, ) -> Result { let amount_msats = Self::amount_msats(invoice_request)?; + let invoice_recurrence = Self::recurrence_fields(invoice_request, recurrence_basetime)?; let signing_pubkey = keys.public_key(); let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), @@ -327,6 +333,7 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + invoice_recurrence, ), }; @@ -348,6 +355,7 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + None, ), }; @@ -408,10 +416,17 @@ macro_rules! invoice_builder_methods { } } + pub(crate) fn recurrence_fields( + _invoice_request: &InvoiceRequest, _recurrence_basetime: Option, + ) -> Result, Bolt12SemanticError> { + todo!("Future commits will introduce the Recurrence Token creation logic") + } + #[cfg_attr(c_bindings, allow(dead_code))] fn fields( payment_paths: Vec, created_at: Duration, payment_hash: PaymentHash, amount_msats: u64, signing_pubkey: PublicKey, + invoice_recurrence: Option, ) -> InvoiceFields { InvoiceFields { payment_paths, @@ -424,6 +439,7 @@ macro_rules! invoice_builder_methods { signing_pubkey, #[cfg(test)] experimental_baz: None, + invoice_recurrence, } } @@ -775,6 +791,18 @@ struct InvoiceFields { signing_pubkey: PublicKey, #[cfg(test)] experimental_baz: Option, + invoice_recurrence: Option, +} + +#[derive(Clone, Debug, PartialEq)] +/// Recurrence fields included in an invoice for a recurring offer. +/// +/// `recurrence_basetime` anchors period 0 for recurring invoices when the offer did not include +/// an explicit recurrence base. `recurrence_token` is the opaque token issued by the payee for +/// the payer to echo in the next recurring [`InvoiceRequest`]. +pub struct InvoiceRecurrence { + recurrence_basetime: u64, + recurrence_token: Vec, } macro_rules! invoice_accessors { ($self: ident, $contents: expr) => { @@ -1415,6 +1443,14 @@ impl InvoiceFields { } }; + let (invoice_recurrence_basetime, invoice_recurrence_token) = match &self.invoice_recurrence + { + None => (None, None), + Some(recurrence) => { + (Some(recurrence.recurrence_basetime), Some(recurrence.recurrence_token.as_ref())) + }, + }; + ( InvoiceTlvStreamRef { paths: Some(Iterable( @@ -1429,6 +1465,8 @@ impl InvoiceFields { features, node_id: Some(&self.signing_pubkey), held_htlc_available_paths: None, + invoice_recurrence_basetime, + invoice_recurrence_token, }, ExperimentalInvoiceTlvStreamRef { #[cfg(test)] @@ -1510,6 +1548,8 @@ tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, { (172, fallbacks: (Vec, WithoutLength)), (174, features: (Bolt12InvoiceFeatures, WithoutLength)), (176, node_id: PublicKey), + (177, invoice_recurrence_basetime: (u64, HighZeroBytesDroppedBigSize)), + (179, invoice_recurrence_token: (Vec, WithoutLength)), // Only present in `StaticInvoice`s. (236, held_htlc_available_paths: (Vec, WithoutLength)), }); @@ -1701,6 +1741,8 @@ impl TryFrom for InvoiceContents { features, node_id, held_htlc_available_paths, + invoice_recurrence_basetime, + invoice_recurrence_token, }, experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream, @@ -1731,6 +1773,14 @@ impl TryFrom for InvoiceContents { let signing_pubkey = node_id.ok_or(Bolt12SemanticError::MissingSigningPubkey)?; + let invoice_recurrence = match (invoice_recurrence_basetime, invoice_recurrence_token) { + (None, None) => None, + (Some(basetime), Some(token)) => { + Some(InvoiceRecurrence { recurrence_basetime: basetime, recurrence_token: token }) + }, + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + }; + let fields = InvoiceFields { payment_paths, created_at, @@ -1742,6 +1792,7 @@ impl TryFrom for InvoiceContents { signing_pubkey, #[cfg(test)] experimental_baz, + invoice_recurrence, }; check_invoice_signing_pubkey(&fields.signing_pubkey, &offer_tlv_stream)?; @@ -1759,6 +1810,10 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::InvalidAmount); } + if fields.invoice_recurrence.is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + Ok(InvoiceContents::ForRefund { refund, fields }) } else { let invoice_request = InvoiceRequestContents::try_from(( @@ -2047,6 +2102,8 @@ mod tests { features: None, node_id: Some(&recipient_pubkey()), held_htlc_available_paths: None, + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -2159,6 +2216,8 @@ mod tests { features: None, node_id: Some(&recipient_pubkey()), held_htlc_available_paths: None, + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index edc1758b73c..b1b599b193f 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -91,11 +91,14 @@ use crate::util::ser::{ CursorReadable, HighZeroBytesDroppedBigSize, LengthLimitedRead, LengthReadable, Readable, WithoutLength, Writeable, Writer, }; + use bitcoin::constants::ChainHash; use bitcoin::network::Network; use bitcoin::secp256k1::schnorr::Signature; use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1}; +use core::time::Duration; + #[cfg(not(c_bindings))] use crate::offers::invoice::InvoiceBuilder; #[cfg(c_bindings)] @@ -641,6 +644,8 @@ pub struct VerifiedInvoiceRequest { /// The verified request. pub(crate) inner: InvoiceRequest, + pub(crate) resolved_basetime: Option, + /// Keys for signing a [`Bolt12Invoice`] for the request. /// #[cfg_attr( @@ -895,8 +900,42 @@ impl UnsignedInvoiceRequest { invoice_request_accessors!(self, self.contents); } +macro_rules! invoice_request_recurrence_methods { ( + $self: ident, $contents: expr +) => { + pub(crate) fn recurrence_basetime( + &$self, created_at: Duration, resolved_basetime: Option, + ) -> Result, Bolt12SemanticError> { + let offer_recurrence = match $self.offer_recurrence() { + Some(offer_recurrence) => offer_recurrence, + None => return Ok(None), + }; + + let offer_base = match offer_recurrence { + Recurrence::Optional { .. } => None, + Recurrence::Compulsory { base, .. } => base, + }; + + let recurrence_counter = $contents + .invoice_request_recurrence() + .as_ref() + .and_then(|recurrence| recurrence.fields().0); + + match (offer_base, recurrence_counter) { + (Some(base), _) => Ok(Some(base.basetime)), + (None, Some(0)) => Ok(Some(created_at.as_secs())), + (None, Some(_)) => { + resolved_basetime + .map(Some) + .ok_or(Bolt12SemanticError::InvalidMetadata) + }, + (None, None) => Ok(Some(created_at.as_secs())), + } + } +}; } + macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( - $self: ident, $contents: expr, $builder: ty + $self: ident, $contents: expr, $basetime: expr, $builder: ty ) => { /// Creates an [`InvoiceBuilder`] for the request with the given required fields and using the /// [`Duration`] since [`std::time::SystemTime::UNIX_EPOCH`] as the creation time. @@ -954,7 +993,9 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( None => return Err(Bolt12SemanticError::MissingIssuerSigningPubkey), }; - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + let recurrence_basetime = $self.recurrence_basetime(created_at, $basetime)?; + + <$builder>::for_offer(&$contents, payment_paths, created_at, recurrence_basetime, payment_hash, signing_pubkey) } #[cfg(test)] @@ -969,14 +1010,34 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( return Err(Bolt12SemanticError::UnknownRequiredFeatures); } - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + let recurrence_basetime = $self.recurrence_basetime(created_at, $basetime)?; + + <$builder>::for_offer(&$contents, payment_paths, created_at, recurrence_basetime, payment_hash, signing_pubkey) } } } macro_rules! invoice_request_verify_method { ($self: ident, $self_type: ty) => { + /// Verifies the speculative recurrence token echoed by the payer and resolves the recurrence + /// basetime to reuse for subsequent invoices. + /// + /// Returns `Ok(None)` when the request carries no recurrence token. + // Token validation and basetime recovery for token-carrying requests are deferred to follow-up work. + pub(crate) fn verify_recurrence_token( + $self: &$self_type, _key: &ExpandedKey, + ) -> Result, ()> { + let recurrence_token = + $self.invoice_request_recurrence().as_ref().and_then(|rec| rec.fields().2); + + if recurrence_token.is_some() { + // TODO: "Token verification and basetime resolve logic to be implemented in follow-up commits." + Err(()) + } else { + Ok(None) + } + } /// Verifies that the request was for an offer created using the given key by checking the - /// metadata from the offer. + /// metadata from the offer. /// /// Returns the verified request which contains the derived keys needed to sign a /// [`Bolt12Invoice`] for the request if they could be extracted from the metadata. @@ -1002,15 +1063,19 @@ macro_rules! invoice_request_verify_method { { $self.clone() } }; + let resolved_basetime = inner.verify_recurrence_token(key)?; + let verified = match keys { None => InvoiceRequestVerifiedFromOffer::ExplicitKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: ExplicitSigningPubkey {}, }), Some(keys) => InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: DerivedSigningPubkey(keys), }), }; @@ -1019,7 +1084,7 @@ macro_rules! invoice_request_verify_method { } /// Verifies that the request was for an offer created using the given key by checking a nonce - /// included with the [`BlindedMessagePath`] for which the request was sent through. + /// included with the [`BlindedMessagePath`] for which the request was sent through. /// /// Returns the verified request which contains the derived keys needed to sign a /// [`Bolt12Invoice`] for the request if they could be extracted from the metadata. @@ -1047,15 +1112,19 @@ macro_rules! invoice_request_verify_method { { $self.clone() } }; + let resolved_basetime = inner.verify_recurrence_token(key)?; + let verified = match keys { None => InvoiceRequestVerifiedFromOffer::ExplicitKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: ExplicitSigningPubkey {}, }), Some(keys) => InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: DerivedSigningPubkey(keys), }), }; @@ -1069,9 +1138,12 @@ macro_rules! invoice_request_verify_method { impl InvoiceRequest { offer_accessors!(self, self.contents.inner.offer); invoice_request_accessors!(self, self.contents); + invoice_request_recurrence_methods!(self, self.contents); + invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self, + None, InvoiceBuilder<'_, ExplicitSigningPubkey> ); invoice_request_verify_method!(self, Self); @@ -1086,9 +1158,12 @@ impl InvoiceRequest { impl InvoiceRequest { offer_accessors!(self, self.contents.inner.offer); invoice_request_accessors!(self, self.contents); + invoice_request_recurrence_methods!(self, self.contents); + invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self, + None, InvoiceWithExplicitSigningPubkeyBuilder ); invoice_request_verify_method!(self, &Self); @@ -1128,7 +1203,7 @@ impl InvoiceRequest { } macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( - $self: ident, $contents: expr, $builder: ty + $self: ident, $contents: expr, $basetime: expr, $builder: ty ) => { /// Creates an [`InvoiceBuilder`] for the request using the given required fields and that uses /// derived signing keys from the originating [`Offer`] to sign the [`Bolt12Invoice`]. Must use @@ -1170,8 +1245,10 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( None => return Err(Bolt12SemanticError::MissingIssuerSigningPubkey), } + let recurrence_basetime = $self.recurrence_basetime(created_at, $basetime)?; + <$builder>::for_offer_using_keys( - &$self.inner, payment_paths, created_at, payment_hash, keys + &$self.inner, payment_paths, created_at, recurrence_basetime, payment_hash, keys ) } } } @@ -1216,16 +1293,20 @@ impl VerifiedInvoiceRequest { invoice_request_accessors!(self, self.inner.contents); fields_accessor!(self, self.inner.contents); + invoice_request_recurrence_methods!(self, self.inner.contents); + #[cfg(not(c_bindings))] invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceBuilder<'_, DerivedSigningPubkey> ); #[cfg(c_bindings)] invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceWithDerivedSigningPubkeyBuilder ); } @@ -1235,16 +1316,20 @@ impl VerifiedInvoiceRequest { invoice_request_accessors!(self, self.inner.contents); fields_accessor!(self, self.inner.contents); + invoice_request_recurrence_methods!(self, self.inner.contents); + #[cfg(not(c_bindings))] invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceBuilder<'_, ExplicitSigningPubkey> ); #[cfg(c_bindings)] invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceWithExplicitSigningPubkeyBuilder ); } diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 69913b4390f..740524f1f4a 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -129,6 +129,10 @@ impl<'a> StaticInvoiceBuilder<'a> { return Err(Bolt12SemanticError::UnexpectedChain); } + if offer.offer_recurrence().is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + if payment_paths.is_empty() || held_htlc_available_paths.is_empty() || offer.paths().is_empty() @@ -483,6 +487,8 @@ impl InvoiceContents { node_id: Some(&self.signing_pubkey), amount: None, payment_hash: None, + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }; let experimental_invoice = ExperimentalInvoiceTlvStreamRef { @@ -682,6 +688,8 @@ impl TryFrom for InvoiceContents { held_htlc_available_paths, payment_hash, amount, + invoice_recurrence_basetime, + invoice_recurrence_token, }, experimental_offer_tlv_stream, ExperimentalInvoiceTlvStream { @@ -697,6 +705,10 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::UnexpectedAmount); } + if invoice_recurrence_basetime.is_some() || invoice_recurrence_token.is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + let payment_paths = construct_payment_paths(blindedpay, paths)?; let held_htlc_available_paths = held_htlc_available_paths.ok_or(Bolt12SemanticError::MissingPaths)?; @@ -720,8 +732,14 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::UnexpectedChain); } + let offer = OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?; + + if offer.offer_recurrence().is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + Ok(InvoiceContents { - offer: OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?, + offer, payment_paths, held_htlc_available_paths, created_at, @@ -937,6 +955,8 @@ mod tests { features: None, node_id: Some(&signing_pubkey), held_htlc_available_paths: Some(&paths), + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, From 83863de09aabbcf4574a2c8bb6744733999e590d Mon Sep 17 00:00:00 2001 From: shaavan Date: Sun, 10 May 2026 17:05:09 +0530 Subject: [PATCH 6/6] [test] Cover invoice recurrence parsing Add targeted coverage for the invoice recurrence fields introduced by the preceding feature commit. The new tests prove that recurring invoice TLVs round-trip on offer invoices, that incomplete recurrence TLVs are rejected, and that refund/static invoice parsers still reject unexpected recurrence data. Co-Authored-By: OpenAI Codex --- lightning/src/offers/invoice.rs | 165 ++++++++++++++++++++++++- lightning/src/offers/static_invoice.rs | 71 +++++++++++ 2 files changed, 233 insertions(+), 3 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index e835d0552db..bbeeb99e71e 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -419,7 +419,8 @@ macro_rules! invoice_builder_methods { pub(crate) fn recurrence_fields( _invoice_request: &InvoiceRequest, _recurrence_basetime: Option, ) -> Result, Bolt12SemanticError> { - todo!("Future commits will introduce the Recurrence Token creation logic") + //TODO: Future commits will introduce the recurrence token creation logic + return Ok(None) } #[cfg_attr(c_bindings, allow(dead_code))] @@ -1880,8 +1881,9 @@ pub(super) fn check_invoice_signing_pubkey( mod tests { use super::{ Bolt12Invoice, ExperimentalInvoiceTlvStreamRef, FallbackAddress, FullInvoiceTlvStreamRef, - InvoiceTlvStreamRef, UnsignedBolt12Invoice, DEFAULT_RELATIVE_EXPIRY, - EXPERIMENTAL_INVOICE_TYPES, INVOICE_TYPES, SIGNATURE_TAG, + InvoiceContents, InvoiceFields, InvoiceRecurrence, InvoiceTlvStreamRef, + UnsignedBolt12Invoice, DEFAULT_RELATIVE_EXPIRY, EXPERIMENTAL_INVOICE_TYPES, INVOICE_TYPES, + SIGNATURE_TAG, }; use bitcoin::address::Address; @@ -3452,6 +3454,163 @@ mod tests { } } + #[test] + fn parses_invoice_with_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_basetime = 123_456; + let recurrence_token = vec![1, 2, 3]; + + let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let contents = InvoiceContents::ForOffer { + invoice_request: invoice_request.contents.clone(), + fields: InvoiceFields { + payment_paths: payment_paths(), + created_at: now(), + relative_expiry: None, + payment_hash: payment_hash(), + amount_msats: 1000, + fallbacks: None, + features: Bolt12InvoiceFeatures::empty(), + signing_pubkey: recipient_pubkey(), + #[cfg(test)] + experimental_baz: None, + invoice_recurrence: Some(InvoiceRecurrence { + recurrence_basetime, + recurrence_token: recurrence_token.clone(), + }), + }, + }; + + let invoice = UnsignedBolt12Invoice::new(invoice_request.bytes(), contents) + .sign(recipient_sign) + .unwrap(); + + let mut buffer = Vec::new(); + invoice.write(&mut buffer).unwrap(); + + match Bolt12Invoice::try_from(buffer) { + Ok(invoice) => { + match &invoice.contents { + InvoiceContents::ForOffer { fields, .. } => { + assert_eq!( + fields.invoice_recurrence, + Some(InvoiceRecurrence { + recurrence_basetime, + recurrence_token: recurrence_token.clone(), + }) + ); + }, + InvoiceContents::ForRefund { .. } => panic!("expected offer invoice"), + } + + let tlv_stream = invoice.as_tlv_stream(); + assert_eq!(tlv_stream.3.invoice_recurrence_basetime, Some(recurrence_basetime)); + assert_eq!(tlv_stream.3.invoice_recurrence_token, Some(&recurrence_token)); + }, + Err(e) => panic!("error parsing invoice: {:?}", e), + } + } + + #[test] + fn fails_parsing_invoice_with_invalid_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_token = vec![1, 2, 3]; + + let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let contents = InvoiceContents::ForOffer { + invoice_request: invoice_request.contents.clone(), + fields: InvoiceFields { + payment_paths: payment_paths(), + created_at: now(), + relative_expiry: None, + payment_hash: payment_hash(), + amount_msats: 1000, + fallbacks: None, + features: Bolt12InvoiceFeatures::empty(), + signing_pubkey: recipient_pubkey(), + #[cfg(test)] + experimental_baz: None, + invoice_recurrence: Some(InvoiceRecurrence { + recurrence_basetime: 123_456, + recurrence_token: recurrence_token.clone(), + }), + }, + }; + + let invoice = UnsignedBolt12Invoice::new(invoice_request.bytes(), contents) + .sign(recipient_sign) + .unwrap(); + + let mut missing_token = invoice.as_tlv_stream(); + missing_token.3.invoice_recurrence_token = None; + + match Bolt12Invoice::try_from(missing_token.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + + let mut missing_basetime = invoice.as_tlv_stream(); + missing_basetime.3.invoice_recurrence_basetime = None; + + match Bolt12Invoice::try_from(missing_basetime.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + } + + #[test] + fn fails_parsing_refund_invoice_with_recurrence() { + let refund = + RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap().build().unwrap(); + + let invoice = refund + .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let recurrence_token = vec![1, 2, 3]; + let mut tlv_stream = invoice.as_tlv_stream(); + tlv_stream.3.invoice_recurrence_basetime = Some(123_456); + tlv_stream.3.invoice_recurrence_token = Some(&recurrence_token); + + match Bolt12Invoice::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ), + } + } + #[test] fn parses_invoice_with_experimental_tlv_records() { let expanded_key = ExpandedKey::new([42; 32]); diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 740524f1f4a..0a68c5572ba 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -768,6 +768,7 @@ mod tests { use crate::offers::nonce::Nonce; use crate::offers::offer::{ ExperimentalOfferTlvStreamRef, Offer, OfferBuilder, OfferTlvStreamRef, Quantity, + RecurrencePeriod, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::static_invoice::{ @@ -1677,6 +1678,39 @@ mod tests { } } + #[test] + fn fails_parsing_invoice_with_recurrence() { + let invoice = invoice(); + let recurrence_token = vec![1, 2, 3]; + let mut tlv_stream = invoice.as_tlv_stream(); + tlv_stream.1.invoice_recurrence_basetime = Some(123_456); + tlv_stream.1.invoice_recurrence_token = Some(&recurrence_token); + + match StaticInvoice::try_from(tlv_stream_to_bytes(&tlv_stream)) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ), + } + } + + #[test] + fn fails_parsing_invoice_with_offer_recurrence() { + let invoice = invoice(); + let recurrence_period = RecurrencePeriod::Days(14); + let mut tlv_stream = invoice.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + + match StaticInvoice::try_from(tlv_stream_to_bytes(&tlv_stream)) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ), + } + } + #[test] fn fails_parsing_invoice_with_invalid_offer_fields() { // Error if the offer is missing paths. @@ -1744,4 +1778,41 @@ mod tests { assert_eq!(invoice.offer_id(), offer_id); } + + #[test] + fn fails_building_invoice_from_offer_with_recurrence() { + let node_id = recipient_pubkey(); + let now = now(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + + let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) + .path(blinded_path()) + .build() + .unwrap(); + + let recurrence_period = RecurrencePeriod::Days(14); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + + let mut offer_bytes = Vec::new(); + tlv_stream.0.write(&mut offer_bytes).unwrap(); + tlv_stream.1.write(&mut offer_bytes).unwrap(); + let offer = Offer::try_from(offer_bytes).unwrap(); + + match StaticInvoiceBuilder::for_offer_using_derived_keys( + &offer, + payment_paths(), + vec![blinded_path()], + now, + &expanded_key, + nonce, + &secp_ctx, + ) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::UnexpectedRecurrence), + } + } }