From d6dd3c55b311cb1e69668023fcfc591a06782867 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Sep 2025 18:26:22 +0530 Subject: [PATCH 1/8] Introduce recurrence fields in Offer (BOLT12 PoC) This commit begins the introduction of BOLT12 recurrence support in LDK. It adds the core recurrence-related fields to `Offer`, enabling subscription-style and periodic payments as described in the draft spec. Since this is a PoC, the focus is on establishing the data model and documenting the intended semantics. Where the spec is ambiguous or redundant, accompanying comments note possible simplifications or improvements. This lays the foundation for the following commits, which will implement invoice-request parsing, payee-side validation, and period/paywindow handling. Spec reference: https://github.com/rustyrussell/bolts/blob/guilt/offers-recurrence/12-offer-encoding.md#tlv-fields-for-offers --- lightning/src/offers/invoice.rs | 10 + lightning/src/offers/invoice_request.rs | 5 + lightning/src/offers/offer.rs | 371 ++++++++++++++++++++++++ lightning/src/offers/parse.rs | 4 + lightning/src/offers/refund.rs | 24 ++ lightning/src/offers/static_invoice.rs | 5 + 6 files changed, 419 insertions(+) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 6dfd6eac508..68d76e8d8be 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1987,6 +1987,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, @@ -2090,6 +2095,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 4311d194dca..82962314d87 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 7ad3c282c77..d9898ff6514 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_fields: 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_fields: None, #[cfg(test)] experimental_foo: None, }, @@ -632,10 +634,269 @@ pub(super) struct OfferContents { paths: Option>, supported_quantity: Quantity, issuer_signing_pubkey: Option, + recurrence_fields: Option, #[cfg(test)] experimental_foo: Option, } +/// The unit in which a [`Recurrence`] period is expressed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimeUnit { + /// Periods measured in seconds. + Seconds, + /// Periods measured in whole days. + Days, + /// Periods measured in whole calendar months. + Months, +} + +/// Represents the recurrence period as `(time_unit, count)`. +/// +/// 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 struct Recurrence { + /// The unit of time (seconds, days, months). + pub time_unit: TimeUnit, + /// Number of `time_unit`s that make up one recurrence period. + pub period: u32, +} + +impl Recurrence { + /// Returns an approximate length of one recurrence period in seconds. + /// + /// This is a helper for timing checks (for example, paywindow validation), + /// not a full implementation of the BOLT12 calendar rules for days and months. + /// + /// Approximations: + /// - Seconds: 1 second per unit + /// - Days: 86_400 seconds per day + /// - Months: 30 days per month (2_592_000 seconds) + /// + /// This intentionally trades exact calendar correctness for simplicity + /// while the implementation is at a proof of concept stage. + pub fn period_length_secs(&self) -> Option { + let factor = match self.time_unit { + TimeUnit::Seconds => 1u64, + TimeUnit::Days => 86_400, + TimeUnit::Months => 2_592_000, + }; + + (self.period as u64).checked_mul(factor).or_else(|| { + debug_assert!(false, "recurrence period length overflowed u64"); + None + }) + } +} + +impl Writeable for Recurrence { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + match &self.time_unit { + TimeUnit::Seconds => 0u8.write(writer)?, + TimeUnit::Days => 1u8.write(writer)?, + TimeUnit::Months => 2u8.write(writer)?, + } + + HighZeroBytesDroppedBigSize(self.period).write(writer) + } +} + +impl Readable for Recurrence { + fn read(r: &mut R) -> Result { + let time_unit_byte = Readable::read(r)?; + let time_unit = match time_unit_byte { + 0u8 => TimeUnit::Seconds, + 1u8 => TimeUnit::Days, + 2u8 => TimeUnit::Months, + _ => return Err(DecodeError::InvalidValue), + }; + + let period: HighZeroBytesDroppedBigSize = Readable::read(r)?; + + if period.0 == 0 { + return Err(DecodeError::InvalidValue); + } + + Ok(Recurrence { time_unit, period: period.0 }) + } +} + +/// 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, the timestamp of the first invoice creation +/// is used as the starting point. +/// +/// --- +/// 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> { + HighZeroBytesDroppedBigSize(self.seconds_before).write(writer)?; + HighZeroBytesDroppedBigSize(self.seconds_after).write(writer) + } +} + +impl Readable for RecurrencePaywindow { + fn read(r: &mut R) -> Result { + let before: HighZeroBytesDroppedBigSize = Readable::read(r)?; + let after: HighZeroBytesDroppedBigSize = Readable::read(r)?; + Ok(RecurrencePaywindow { seconds_before: before.0, seconds_after: after.0 }) + } +} + +/// 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. +/// +/// Design note: +/// Instead of storing `recurrence_optional` and `recurrence_compulsory` as two +/// separate enum variants, we collapse them into a single struct, and determine +/// whether the offer is optional or compulsory based on which fields are present. +/// +/// Rationale for this approach: +/// +/// 1. **No behavioral difference without `recurrence_base`.** +/// If `recurrence_base` is absent, both optional and compulsory recurrence +/// behave the same from the payer’s perspective. In that case, defaulting to +/// `recurrence_optional` is simpler and avoids unnecessary strictness. +/// +/// 2. **Graceful upgrade for nodes without recurrence support.** +/// If LDK creates an offer *without* `recurrence_base`, marking it as +/// optional lets older nodes (that don't understand recurrence) still make at +/// least a one-time payment. We only switch to compulsory when the spec +/// demands it — that is, when `recurrence_base` is present. +/// +/// 3. **Payer logic remains consistent.** +/// A payer: +/// - without recurrence support will only be able to pay optional offers; +/// - with recurrence support treats both optional and compulsory offers +/// the same, except for the presence/absence of `recurrence_base`. +/// +/// We do not lose any information, because during payment we use the raw +/// TLV bytes we received (not a re-serialized form). +/// +/// Summary: +/// - If `recurrence_base` is present → the offer must be treated as +/// recurrence_compulsory. +/// - If it is absent → we default to recurrence_optional. +/// - Other fields (`paywindow`, `limit`) apply identically in both cases. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceFields { + /// The recurrence schedule: period length and unit. + pub recurrence: Recurrence, + /// The anchor time for period 0, if the Offer defines one. + /// + /// When present, recurrence becomes compulsory. + pub recurrence_base: Option, + /// 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, +} + 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 +969,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_fields(&$self) -> Option<$crate::offers::offer::RecurrenceFields> { + $contents.recurrence_fields() + } } } impl Offer { @@ -993,6 +1259,10 @@ impl OfferContents { self.issuer_signing_pubkey } + pub fn recurrence_fields(&self) -> Option { + self.recurrence_fields + } + pub(super) fn verify_using_metadata( &self, bytes: &[u8], key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result<(OfferId, Option), ()> { @@ -1060,6 +1330,23 @@ impl OfferContents { } }; + let ( + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, + ) = self.recurrence_fields.as_ref().map_or((None, None, None, None, None), |r| { + let base = r.recurrence_base.as_ref(); + let paywindow = r.recurrence_paywindow.as_ref(); + let limit = r.recurrence_limit.as_ref(); + + match base { + Some(_) => (Some(&r.recurrence), None, base, paywindow, limit), + None => (None, Some(&r.recurrence), base, paywindow, limit), + } + }); + let offer = OfferTlvStreamRef { chains: self.chains.as_ref(), metadata: self.metadata(), @@ -1072,6 +1359,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 +1518,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: Recurrence), + + // (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: Recurrence), + + // (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 +1619,11 @@ impl TryFrom for OfferContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -1341,6 +1670,41 @@ impl TryFrom for OfferContents { (issuer_id, paths) => (issuer_id, paths), }; + // Normalize recurrence TLVs during deserialization. + // + // During serialization we control which TLVs appear: + // - no basetime → use `recurrence_optional` + // - basetime present → use `recurrence_compulsory` + // + // When *reading*, we instead accept only valid combinations: + // - no recurrence TLVs, + // - `recurrence_optional` without a basetime, + // - `recurrence_compulsory` paired with a basetime. + // Everything else is invalid and rejected. + let recurrence_fields = match (recurrence_compulsory, recurrence_optional, recurrence_base) + { + (None, None, None) => None, + + // Base absent → optional period + (None, Some(period), None) => Some(RecurrenceFields { + recurrence: period, + recurrence_base: None, + recurrence_paywindow, + recurrence_limit, + }), + + // Base present → compulsory period + (Some(period), None, Some(base)) => Some(RecurrenceFields { + recurrence: period, + recurrence_base: Some(base), + recurrence_paywindow, + recurrence_limit, + }), + + // Anything else is malformed + _ => return Err(Bolt12SemanticError::InvalidMetadata), + }; + Ok(OfferContents { chains, metadata, @@ -1352,6 +1716,7 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, + recurrence_fields, #[cfg(test)] experimental_foo, }) @@ -1429,6 +1794,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_fields(), None); assert_eq!( offer.as_tlv_stream(), @@ -1445,6 +1811,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 99dd1bb938d..c3e48740437 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -224,6 +224,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 dd2c3e2a92e..ed53419bd06 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -789,6 +789,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 = { @@ -918,6 +923,11 @@ impl TryFrom for RefundContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, InvoiceRequestTlvStream { chain, @@ -979,6 +989,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); @@ -1108,6 +1127,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 77f486a6a06..79a2e7abdf7 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -908,6 +908,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 d97be2561bcf7503b0f3ccf344b0d04e7c6d11a3 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Sep 2025 18:39:08 +0530 Subject: [PATCH 2/8] Introduce recurrence fields in InvoiceRequest (BOLT12 PoC) This commit adds the recurrence-related TLVs to `InvoiceRequest`, allowing payers to specify the intended period index, an optional starting offset, and (when applicable) a recurrence cancellation signal. Spec reference: https://github.com/rustyrussell/bolts/blob/guilt/offers-recurrence/12-offer-encoding.md#tlv-fields-for-invoice_request --- fuzz/src/invoice_request_deser.rs | 1 + lightning/src/ln/offers_tests.rs | 6 + lightning/src/offers/invoice.rs | 6 + lightning/src/offers/invoice_request.rs | 199 +++++++++++++++++++++++- lightning/src/offers/refund.rs | 14 ++ 5 files changed, 225 insertions(+), 1 deletion(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..ba33adac0ea 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, + recurrence_counter: None, } }; diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 4c53aefe58d..6ef822c609a 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -683,6 +683,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + recurrence_counter: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -841,6 +842,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + recurrence_counter: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -962,6 +964,7 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + recurrence_counter: None, }, }); @@ -1229,6 +1232,7 @@ fn creates_and_pays_for_offer_with_retry() { quantity: None, payer_note_truncated: None, human_readable_name: None, + recurrence_counter: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1294,6 +1298,7 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + recurrence_counter: None, }, }); @@ -1391,6 +1396,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { quantity: None, payer_note_truncated: None, human_readable_name: None, + recurrence_counter: None, }, }); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 68d76e8d8be..c7a7a629e56 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -2002,6 +2002,9 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( @@ -2110,6 +2113,9 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 82962314d87..db2d649d725 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -186,7 +186,8 @@ macro_rules! invoice_request_builder_methods { ( 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, recurrence_counter: None, recurrence_start: None, + recurrence_cancel: None, #[cfg(test)] experimental_bar: None, } @@ -686,6 +687,36 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, + /// Recurrence counter for this invoice request. + /// + /// This is the Nth invoice request the payer is making for this offer. + /// Important: this does *not* necessarily equal the Nth period of the recurrence. + /// + /// The actual period index is: + /// period_index = recurrence_start + recurrence_counter + /// + /// The counter implicitly assumes that all earlier payments + /// (0 .. recurrence_counter-1) were successfully completed. + /// The payee does not track past payments; it simply verifies + /// that the incoming counter is the next expected one. + recurrence_counter: Option, + /// Starting offset into the recurrence schedule. + /// + /// Example: If the offer has a basetime of Jan 1st and recurrence period + /// is monthly, and the payer wants to begin on April 1st, then: + /// recurrence_start = 3 + /// + /// This field is only meaningful for offers that define a `recurrence_base`, + /// since offset is defined relative to a fixed basetime. + recurrence_start: Option, + /// Indicates that the payer wishes to *cancel* the recurrence. + /// + /// MUST NOT be set on the first invoice request (counter = 0). + /// + /// When this field is present, the request is effectively a cancellation + /// message; the payee should send invoice corresponding to this stub + /// invoice_request. + recurrence_cancel: Option<()>, #[cfg(test)] experimental_bar: Option, } @@ -736,6 +767,30 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { $contents.payer_signing_pubkey() } + /// Returns the recurrence counter for this invoice request, if present. + /// + /// This indicates which request in the recurrence sequence this is. + /// `None` means the invoice request is not part of a recurrence flow. + pub fn recurrence_counter(&$self) -> Option { + $contents.recurrence_counter() + } + + /// Returns the recurrence start offset, if present. + /// + /// This is only set when the offer defines an absolute recurrence basetime. + /// It indicates from which period the payer wishes to begin. + pub fn recurrence_start(&$self) -> Option { + $contents.recurrence_start() + } + + /// Returns whether this invoice request is cancelling an ongoing recurrence. + /// + /// `Some(())` means the payer wishes to cancel. + /// This MUST NOT be set on the initial request in a recurrence sequence. + pub fn recurrence_cancel(&$self) -> Option<()> { + $contents.recurrence_cancel() + } + /// 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 +1105,7 @@ macro_rules! fields_accessor { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, + recurrence_counter, .. }, } = &$inner; @@ -1063,6 +1119,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(), + recurrence_counter: *recurrence_counter, } } }; @@ -1167,6 +1224,18 @@ impl InvoiceRequestContents { self.inner.quantity } + pub(super) fn recurrence_counter(&self) -> Option { + self.inner.recurrence_counter + } + + pub(super) fn recurrence_start(&self) -> Option { + self.inner.recurrence_start + } + + pub(super) fn recurrence_cancel(&self) -> Option<()> { + self.inner.recurrence_cancel + } + pub(super) fn payer_signing_pubkey(&self) -> PublicKey { self.payer_signing_pubkey } @@ -1222,6 +1291,9 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { payer_note: self.payer_note.as_ref(), offer_from_hrn: self.offer_from_hrn.as_ref(), paths: None, + recurrence_counter: self.recurrence_counter, + recurrence_start: self.recurrence_start, + recurrence_cancel: self.recurrence_cancel.as_ref(), }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { @@ -1282,6 +1354,9 @@ 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: ()), }); /// Valid type range for experimental invoice_request TLV records. @@ -1434,6 +1509,9 @@ impl TryFrom for InvoiceRequestContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { @@ -1470,6 +1548,102 @@ impl TryFrom for InvoiceRequestContents { return Err(Bolt12SemanticError::UnexpectedPaths); } + let offer_recurrence = offer.recurrence_fields(); + let offer_base = offer_recurrence.and_then(|f| f.recurrence_base); + + match ( + offer_recurrence, + offer_base, + recurrence_counter, + recurrence_start, + recurrence_cancel, + ) { + // Offer without recurrence → No recurrence fields should be in IR + (None, None, None, None, None) => { /* OK */ }, + // ------------------------------------------------------------ + // Recurrence OPTIONAL (no basetime) + // ------------------------------------------------------------ + // 1. No fields → treat as normal single payment. Supports backward compatibility. + // Spec Suggestion: + // + // Currently the reader MUST reject any invoice_request that omits + // `invreq_recurrence_counter` when the offer contains recurrence_optional + // or recurrence_compulsory. + // However, recurrence_optional is explicitly intended to preserve + // compatibility with payers that do not implement recurrence. Such payers + // should be able to make a single, non-recurring payment without setting + // any recurrence fields. + // Therefore, for recurrence_optional, it should be valid to omit all + // recurrence-related fields (counter, start, cancel), and the invoice + // request should be treated as a normal single payment. + (Some(_), None, None, None, None) => { /* OK */ }, + // 2. Only counter → payer supports recurrence; starting at counter + (Some(_), None, Some(_), None, None) => { /* OK */ }, + // 3. counter > 0 → allowed cancellation + (Some(_), None, Some(c), None, Some(())) if c > 0 => { /* OK */ }, + // INVALID optional cases: + (Some(_), None, _, Some(_), _) => { + // recurrence_start MUST NOT appear without basetime + return Err(Bolt12SemanticError::InvalidMetadata); + }, + (Some(_), None, Some(c), None, Some(())) if c == 0 => { + // cannot cancel first request + return Err(Bolt12SemanticError::InvalidMetadata); + }, + (Some(_), None, _, _, _) => { + // All other recurrence optional combinations invalid + return Err(Bolt12SemanticError::InvalidMetadata); + }, + // ------------------------------------------------------------ + // Recurrence COMPULSORY (with basetime) + // ------------------------------------------------------------ + + // 1. First request: counter=0, start present, cancel absent + (Some(_), Some(_), Some(0), Some(_), None) => { /* OK */ }, + // 2. Later periods: counter>0, start present, cancel MAY be present + (Some(_), Some(_), Some(c), Some(_), _cancel) if c > 0 => { /* OK */ }, + + // INVALID compulsory cases ------------------------------------ + // Missing counter or start + (Some(_), Some(_), None, _, _) | (Some(_), Some(_), _, None, _) => { + return Err(Bolt12SemanticError::InvalidMetadata); + }, + // Cancel on first request (counter=0) + (Some(_), Some(_), Some(c), Some(_), Some(())) if c == 0 => { + return Err(Bolt12SemanticError::InvalidMetadata); + }, + // Any other recurrence compulsory combination is invalid + (Some(_), Some(_), _, _, _) => { + return Err(Bolt12SemanticError::InvalidMetadata); + }, + // Any other combination is invalid + (_, _, _, _, _) => { + return Err(Bolt12SemanticError::InvalidMetadata); + }, + } + + // Limit, and Paywindow checks. + if let Some(fields) = &offer_recurrence { + if let Some(limit) = fields.recurrence_limit { + // Only enforce limit when recurrence is actually in use. + if let Some(counter) = recurrence_counter { + let offset = recurrence_start.unwrap_or(0); + let period_index = counter.saturating_add(offset); + + if period_index > limit.0 { + return Err(Bolt12SemanticError::InvalidMetadata); + } + } + } + if let Some(_paywindow) = fields.recurrence_paywindow { + // TODO: implement once we compute: + // let period_start_time = ... + // + // if now < period_start_time - paywindow.seconds_before { ... } + // if now >= period_start_time + paywindow.seconds_after { ... } + } + } + Ok(InvoiceRequestContents { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { payer, @@ -1480,6 +1654,9 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, #[cfg(test)] experimental_bar, }, @@ -1505,6 +1682,19 @@ pub struct InvoiceRequestFields { /// The Human Readable Name which the sender indicated they were paying to. pub human_readable_name: Option, + + /// If the invoice request belonged to a recurring offer, this field + /// contains the *recurrence counter* (zero-based). + /// + /// Semantics: + /// - `None` means this payment is not part of a recurrence (either a + /// one-off request, or the payer does not understand recurrence). + /// - `Some(n)` means this payment corresponds to period `n`, where + /// `n` matches the invoice request's `invreq_recurrence_counter`. + /// + /// This is consumed by the payee when the payment is actually claimed, + /// allowing the recurrence state to advance (`next_payable_counter += 1`). + pub recurrence_counter: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1522,6 +1712,7 @@ impl Writeable for InvoiceRequestFields { (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, self.recurrence_counter.map(|v| HighZeroBytesDroppedBigSize(v)), option), }); Ok(()) } @@ -1534,6 +1725,7 @@ 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))), }); Ok(InvoiceRequestFields { @@ -1541,6 +1733,7 @@ impl Readable for InvoiceRequestFields { quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + recurrence_counter, }) } } @@ -1662,6 +1855,9 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -3117,6 +3313,7 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + recurrence_counter: None, } ); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index ed53419bd06..99fe85e0402 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -813,6 +813,9 @@ 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, }; let experimental_offer = ExperimentalOfferTlvStreamRef { @@ -938,6 +941,9 @@ impl TryFrom for RefundContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -1003,6 +1009,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 => { @@ -1142,6 +1153,9 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, From 7deedcd141151d5a5e57ce45dd3151cbcaf27657 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Sep 2025 18:49:13 +0530 Subject: [PATCH 3/8] 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 --- lightning/src/offers/invoice.rs | 120 +++++++++++++++++++++++++ lightning/src/offers/static_invoice.rs | 8 ++ 2 files changed, 128 insertions(+) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index c7a7a629e56..f21cd76890b 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -422,6 +422,7 @@ macro_rules! invoice_builder_methods { fallbacks: None, features: Bolt12InvoiceFeatures::empty(), signing_pubkey, + invoice_recurrence_basetime: None, #[cfg(test)] experimental_baz: None, } @@ -438,6 +439,29 @@ macro_rules! invoice_builder_methods { Ok(Self { invreq_bytes, invoice: contents, signing_pubkey_strategy }) } + + /// Sets the `invoice_recurrence_basetime` inside the invoice contents. + /// + /// This anchors the recurrence schedule for invoices produced in a + /// recurring-offer flow. Must be identical across all invoices in the + /// same recurrence session. + #[allow(dead_code)] + pub(crate) fn set_invoice_recurrence_basetime( + &mut $self, + basetime: u64 + ) { + match &mut $self.invoice { + InvoiceContents::ForOffer { fields, .. } => { + fields.invoice_recurrence_basetime = Some(basetime); + }, + InvoiceContents::ForRefund { .. } => { + debug_assert!( + false, + "set_invoice_recurrence_basetime called on refund invoice" + ); + } + } + } }; } @@ -773,6 +797,36 @@ struct InvoiceFields { fallbacks: Option>, features: Bolt12InvoiceFeatures, signing_pubkey: PublicKey, + /// The recurrence anchor time (UNIX timestamp) for this invoice. + /// + /// Semantics: + /// - If the offer specifies an explicit `recurrence_base`, this MUST equal it. + /// - If the offer does not specify a base, this MUST be the creation time + /// of the *first* invoice in the recurrence sequence. + /// + /// Requirements: + /// - The payee must remember the basetime from the first invoice and reuse it + /// for all subsequent invoices in the recurrence. + /// - The payer must verify that the basetime in each invoice matches the + /// basetime of previously paid periods, ensuring a stable schedule. + /// + /// Practical effect: + /// This timestamp anchors the recurrence period calculation for the entire + /// recurring-payment flow. + /// + /// Spec Commentary: + /// The spec currently requires this field even when the offer already includes + /// its own `recurrence_base`. Since invoices are always prsent alongside their + /// offer, the basetime is already known. Duplicating it across offer → invoice + /// adds redundant equivalence checks without providing new information. + /// + /// Possible simplification: + /// - Include `invoice_recurrence_basetime` **only when** the offer did *not* define one. + /// - Omit it otherwise and treat the offer as the single source of truth. + /// + /// This avoids redundant duplication and simplifies validation while preserving + /// all necessary semantics. + invoice_recurrence_basetime: Option, #[cfg(test)] experimental_baz: Option, } @@ -1402,6 +1456,7 @@ impl InvoiceFields { features, node_id: Some(&self.signing_pubkey), message_paths: None, + invoice_recurrence_basetime: self.invoice_recurrence_basetime, }, ExperimentalInvoiceTlvStreamRef { #[cfg(test)] @@ -1483,6 +1538,7 @@ 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)), // Only present in `StaticInvoice`s. (236, message_paths: (Vec, WithoutLength)), }); @@ -1674,6 +1730,7 @@ impl TryFrom for InvoiceContents { features, node_id, message_paths, + invoice_recurrence_basetime, }, experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream, @@ -1713,6 +1770,7 @@ impl TryFrom for InvoiceContents { fallbacks, features, signing_pubkey, + invoice_recurrence_basetime, #[cfg(test)] experimental_baz, }; @@ -1720,6 +1778,11 @@ impl TryFrom for InvoiceContents { check_invoice_signing_pubkey(&fields.signing_pubkey, &offer_tlv_stream)?; if offer_tlv_stream.issuer_id.is_none() && offer_tlv_stream.paths.is_none() { + // Recurrence should not be present in Refund. + if fields.invoice_recurrence_basetime.is_some() { + return Err(Bolt12SemanticError::InvalidAmount); + } + let refund = RefundContents::try_from(( payer_tlv_stream, offer_tlv_stream, @@ -1742,6 +1805,61 @@ impl TryFrom for InvoiceContents { experimental_invoice_request_tlv_stream, ))?; + // Recurrence checks + if let Some(offer_recurrence) = invoice_request.inner.offer.recurrence_fields() { + // 1. MUST have basetime whenever offer has recurrence (optional or compulsory). + let invoice_basetime = match fields.invoice_recurrence_basetime { + Some(ts) => ts, + None => { + return Err(Bolt12SemanticError::InvalidMetadata); + }, + }; + + let offer_base = offer_recurrence.recurrence_base; + let counter = invoice_request.recurrence_counter(); + + match counter { + // ---------------------------------------------------------------------- + // Case A: No counter (payer does NOT support recurrence) + // Treat as single-payment invoice. + // Basetime MUST still match presence rules (spec), but nothing else here. + // ---------------------------------------------------------------------- + None => { + // Nothing else to validate. + // This invoice is not part of a recurrence sequence. + }, + // ------------------------------------------------------------------ + // Case B: First recurrence invoice (counter = 0) + // ------------------------------------------------------------------ + Some(0) => { + match offer_base { + // Offer defines explicit basetime → MUST match exactly + Some(base) => { + if invoice_basetime != base.basetime { + return Err(Bolt12SemanticError::InvalidMetadata); + } + }, + + // Offer has no basetime → MUST match invoice.created_at + None => { + if invoice_basetime != fields.created_at.as_secs() { + return Err(Bolt12SemanticError::InvalidMetadata); + } + }, + } + }, + // ------------------------------------------------------------------ + // Case C: Successive recurrence invoices (counter > 0) + // ------------------------------------------------------------------ + Some(_counter_gt_0) => { + // Spec says SHOULD check equality with previous invoice basetime. + // We cannot enforce that here. MUST be done upstream. + // + // TODO: Enforce SHOULD: invoice_basetime == previous_invoice_basetime + }, + } + } + if let Some(requested_amount_msats) = invoice_request.amount_msats() { if amount_msats != requested_amount_msats { return Err(Bolt12SemanticError::InvalidAmount); @@ -2019,6 +2137,7 @@ mod tests { features: None, node_id: Some(&recipient_pubkey()), message_paths: None, + invoice_recurrence_basetime: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -2130,6 +2249,7 @@ mod tests { features: None, node_id: Some(&recipient_pubkey()), message_paths: None, + invoice_recurrence_basetime: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 79a2e7abdf7..7671b58472b 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -474,6 +474,7 @@ impl InvoiceContents { node_id: Some(&self.signing_pubkey), amount: None, payment_hash: None, + invoice_recurrence_basetime: None, }; let experimental_invoice = ExperimentalInvoiceTlvStreamRef { @@ -673,6 +674,7 @@ impl TryFrom for InvoiceContents { message_paths, payment_hash, amount, + invoice_recurrence_basetime, }, experimental_offer_tlv_stream, ExperimentalInvoiceTlvStream { @@ -710,6 +712,11 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::UnexpectedChain); } + // Static invoices MUST NOT set recurrence. + if invoice_recurrence_basetime.is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + Ok(InvoiceContents { offer: OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?, payment_paths, @@ -927,6 +934,7 @@ mod tests { features: None, node_id: Some(&signing_pubkey), message_paths: Some(&paths), + invoice_recurrence_basetime: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, From 31b33668c3d7ff77f6f4137c86d35d4dc81fea41 Mon Sep 17 00:00:00 2001 From: shaavan Date: Tue, 28 Oct 2025 19:15:15 +0530 Subject: [PATCH 4/8] Introduce `create_offer_builder_with_recurrence` This begins the payee-side recurrence implementation by adding a dedicated builder API for constructing Offers that include recurrence fields. The new `create_offer_builder_with_recurrence` helper mirrors the existing offer builder but ensures that the recurrence TLVs are always included, making it easier for users to define subscription-style Offers. --- lightning/src/ln/channelmanager.rs | 28 ++++++++++++++++- lightning/src/offers/flow.rs | 48 ++++++++++++++++++++++++++---- lightning/src/offers/offer.rs | 10 +++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 399c51b9d9a..1b617bb6c02 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -95,7 +95,7 @@ use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; -use crate::offers::offer::{Offer, OfferFromHrn}; +use crate::offers::offer::{Offer, OfferFromHrn, RecurrenceFields}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::Refund; use crate::offers::static_invoice::StaticInvoice; @@ -12777,6 +12777,32 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + /// Creates an [`OfferBuilder`] for a recurring offer. + /// + /// This behaves like [`Self::create_offer_builder`] but additionally embeds + /// the recurrence TLVs defined in `recurrence_fields`. + /// + /// Use this when constructing subscription-style offers where each invoice + /// request must correspond to a specific recurrence period. The provided + /// [`RecurrenceFields`] specify: + /// - how often invoices may be requested, + /// - when the first period begins, + /// - optional paywindows, and + /// - optional period limits. + /// + /// Refer to [`Self::create_offer_builder`] for notes on privacy, + /// requirements, and potential failure cases. + pub fn create_offer_builder_with_recurrence( + &$self, + recurrence_fields: RecurrenceFields + ) -> Result<$builder, Bolt12SemanticError> { + let builder = $self.flow.create_offer_builder_with_recurrence( + &*$self.entropy_source, recurrence_fields, $self.get_peers_for_blinded_path() + )?; + + Ok(builder.into()) + } + /// Same as [`Self::create_offer_builder`], but allows specifying a custom [`MessageRouter`] /// instead of using the [`MessageRouter`] provided to the [`ChannelManager`] at construction. /// diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 94a4534c61a..36c8c63f200 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -43,7 +43,7 @@ use crate::offers::invoice_request::{ InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, VerifiedInvoiceRequest, }; use crate::offers::nonce::Nonce; -use crate::offers::offer::{Amount, DerivedMetadata, Offer, OfferBuilder}; +use crate::offers::offer::{Amount, DerivedMetadata, Offer, OfferBuilder, RecurrenceFields}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::{Refund, RefundBuilder}; use crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}; @@ -539,7 +539,7 @@ where } fn create_offer_builder_intern( - &self, entropy_source: ES, make_paths: PF, + &self, entropy_source: ES, recurrence_fields: Option, make_paths: PF, ) -> Result<(OfferBuilder<'_, DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> where ES::Target: EntropySource, @@ -562,6 +562,10 @@ where OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx) .chain_hash(self.chain_hash); + if let Some(recurrence) = recurrence_fields { + builder = builder.recurrence(recurrence); + } + for path in make_paths(node_id, context, secp_ctx)? { builder = builder.path(path) } @@ -601,7 +605,7 @@ where where ES::Target: EntropySource, { - self.create_offer_builder_intern(&*entropy_source, |_, context, _| { + self.create_offer_builder_intern(&*entropy_source, None, |_, context, _| { self.create_blinded_paths(peers, context) .map(|paths| paths.into_iter().take(1)) .map_err(|_| Bolt12SemanticError::MissingPaths) @@ -609,6 +613,40 @@ where .map(|(builder, _)| builder) } + /// Creates an [`OfferBuilder`] for a recurring offer. + /// + /// This behaves like [`Self::create_offer_builder`] but additionally embeds + /// the recurrence TLVs defined in `recurrence_fields`. + /// + /// Use this when constructing subscription-style offers where each invoice + /// request must correspond to a specific recurrence period. The provided + /// [`RecurrenceFields`] specify: + /// - how often invoices may be requested, + /// - when the first period begins, + /// - optional paywindows, and + /// - optional period limits. + /// + /// Refer to [`Self::create_offer_builder`] for notes on privacy, + /// requirements, and potential failure cases. + pub fn create_offer_builder_with_recurrence( + &self, entropy_source: ES, recurrence_fields: RecurrenceFields, + peers: Vec, + ) -> Result, Bolt12SemanticError> + where + ES::Target: EntropySource, + { + self.create_offer_builder_intern( + &*entropy_source, + Some(recurrence_fields), + |_, context, _| { + self.create_blinded_paths(peers, context) + .map(|paths| paths.into_iter().take(1)) + .map_err(|_| Bolt12SemanticError::MissingPaths) + }, + ) + .map(|(builder, _)| builder) + } + /// Same as [`Self::create_offer_builder`], but allows specifying a custom [`MessageRouter`] /// instead of using the one provided via the [`OffersMessageFlow`] parameterization. /// @@ -626,7 +664,7 @@ where ES::Target: EntropySource, { let receive_key = self.get_receive_auth_key(); - self.create_offer_builder_intern(&*entropy_source, |node_id, context, secp_ctx| { + self.create_offer_builder_intern(&*entropy_source, None, |node_id, context, secp_ctx| { router .create_blinded_paths(node_id, receive_key, context, peers, secp_ctx) .map(|paths| paths.into_iter().take(1)) @@ -651,7 +689,7 @@ where where ES::Target: EntropySource, { - self.create_offer_builder_intern(&*entropy_source, |_, _, _| { + self.create_offer_builder_intern(&*entropy_source, None, |_, _, _| { Ok(message_paths_to_always_online_node) }) } diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index d9898ff6514..841e8b53da4 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -391,6 +391,16 @@ macro_rules! offer_builder_methods { ( $return_value } + /// Set the [Offer::recurrence_fields] for the offer. + /// + /// Successive calls to this method will override the previous setting. + pub fn recurrence( + $($self_mut)* $self: $self_type, recurrence: RecurrenceFields, + ) -> $return_type { + $self.offer.recurrence_fields = Some(recurrence); + $return_value + } + /// Sets the quantity of items for [`Offer::supported_quantity`]. If not called, defaults to /// [`Quantity::One`]. /// From b1bee42c0510361e9c26bcaabc712f353a9da289 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sun, 23 Nov 2025 20:03:32 +0530 Subject: [PATCH 5/8] Introduce `active_recurrence_sessions` (PoC state tracker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a minimal state tracker in `ChannelManager` for handling inbound recurring BOLT12 payments. Each entry records the payer’s recurrence progress (offset, next expected counter, and basetime), giving the payee enough information to validate successive `invoice_request`s and produce consistent invoices. LDK inbound payments have historically been fully stateless. Introducing a stateful mechanism here is a deliberate PoC choice to make recurrence behavior correct and testable end-to-end. For production, we may instead push this state to the user layer, or provide hooks so nodes can manage their own recurrence state externally. For now, this internal tracker gives us a clear foundation to build and evaluate the recurrence flow. --- lightning/src/ln/channelmanager.rs | 22 +++++++++++++- lightning/src/offers/offer.rs | 46 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1b617bb6c02..e3a7d3f2065 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -95,7 +95,7 @@ use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; -use crate::offers::offer::{Offer, OfferFromHrn, RecurrenceFields}; +use crate::offers::offer::{Offer, OfferFromHrn, RecurrenceData, RecurrenceFields}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::Refund; use crate::offers::static_invoice::StaticInvoice; @@ -2671,6 +2671,20 @@ pub struct ChannelManager< #[cfg(not(test))] flow: OffersMessageFlow, + /// Tracks all active recurrence sessions for this node. + /// + /// Each entry is keyed by the payer’s `payer_signing_pubkey` from the + /// initial `invoice_request`. The associated `RecurrenceData` stores + /// everything the payee needs to validate incoming `invoice_request`s + /// and generate invoices for the appropriate recurrence period. + /// + /// This is used by the payee to: + /// - verify the correctness of each incoming `invoice_request` + /// (period offset, counter, basetime, etc.) + /// - ensure continuity across periods + /// - maintain recurrence state until cancellation or completion. + active_recurrence_sessions: Mutex>, + /// See `ChannelManager` struct-level documentation for lock order requirements. #[cfg(any(test, feature = "_test_utils"))] pub(super) best_block: RwLock, @@ -3960,6 +3974,8 @@ where router, flow, + active_recurrence_sessions: Mutex::new(new_hash_map()), + best_block: RwLock::new(params.best_block), outbound_scid_aliases: Mutex::new(new_hash_set()), @@ -17285,6 +17301,7 @@ where let mut inbound_payment_id_secret = None; let mut peer_storage_dir: Option)>> = None; let mut async_receive_offer_cache: AsyncReceiveOfferCache = AsyncReceiveOfferCache::new(); + let mut active_recurrence_sessions = Some(new_hash_map()); read_tlv_fields!(reader, { (1, pending_outbound_payments_no_retry, option), (2, pending_intercepted_htlcs, option), @@ -17303,6 +17320,7 @@ where (17, in_flight_monitor_updates, option), (19, peer_storage_dir, optional_vec), (21, async_receive_offer_cache, (default_value, async_receive_offer_cache)), + (23, active_recurrence_sessions, option), }); let mut decode_update_add_htlcs = decode_update_add_htlcs.unwrap_or_else(|| new_hash_map()); let peer_storage_dir: Vec<(PublicKey, Vec)> = peer_storage_dir.unwrap_or_else(Vec::new); @@ -18197,6 +18215,8 @@ where router: args.router, flow, + active_recurrence_sessions: Mutex::new(active_recurrence_sessions.unwrap()), + best_block: RwLock::new(best_block), inbound_payment_key: expanded_inbound_key, diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 841e8b53da4..969dbb9198e 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -859,6 +859,52 @@ impl Readable for RecurrenceLimit { } } +/// State required for a node to act as a BOLT12 recurrence payee. +/// +/// This tracks the information needed to validate incoming +/// `invoice_request`s for a given recurrence session and +/// to produce consistent invoices across all periods. +pub struct RecurrenceData { + /// The period offset established by the payer in the first + /// recurrence-enabled `invoice_request`. + /// + /// This is `Some(offset)` only when the original Offer defined an explicit + /// `recurrence_base`. In that case, every subsequent invoice must use the + /// **same** offset to remain aligned with the Offer’s schedule. + /// + /// If the Offer did not define a basetime, this will be `None` and period + /// alignment is determined solely by the `recurrence_basetime`. + pub invoice_request_start: Option, + + /// The next expected recurrence period counter. + /// + /// Instead of storing the last seen `recurrence_counter`, we store the + /// *next expected counter*. This simplifies validation: + /// `incoming_counter == next_payable_counter` + /// means the payer is correctly advancing through periods. + /// + /// This also avoids off-by-one confusion and gives the payee a single, + /// stable point of truth for the expected next invoice. + pub next_payable_counter: u32, + + /// The recurrence anchor time for this session. + /// + /// This is: + /// - the Offer’s `recurrence_base` if one was provided, or + /// - the `created_at` timestamp of the **first** invoice otherwise. + /// + /// This value must remain identical across *all* invoices in the session, + /// and is a requirement of the BOLT12 spec. The payee uses it to populate + /// `invoice_recurrence_basetime` consistently for every period. + pub recurrence_basetime: u64, +} + +impl_writeable_tlv_based!(RecurrenceData, { + (0, invoice_request_start, option), + (2, next_payable_counter, required), + (4, recurrence_basetime, required), +}); + /// Represents the recurrence-related fields in an Offer. /// /// Design note: From 6a744ae85bb42b9e935b8bf5fa07537f767a861f Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 8 Sep 2025 16:12:30 +0530 Subject: [PATCH 6/8] Refactor: unify invoice builder response paths (remove `respond_with*` split) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactor removes the separate `respond_with` / `respond_with_no_std` variants and replaces them with a single unified `respond_using_derived_keys(created_at)` API. Reasoning: - Upcoming recurrence logic requires setting `invoice_recurrence_basetime` based on the invoice’s `created_at` timestamp. - For consistency with Offer and Refund builders, we want a single method that accepts an explicit `created_at` value at the callsite. - The only real difference between the std/no_std response paths was how `created_at` was sourced; once it becomes a parameter, the split becomes unnecessary. This change consolidates the response flow, reduces API surface, and makes future recurrence-related changes simpler and more uniform across Offer, InvoiceRequest, and Refund builders. --- fuzz/src/invoice_request_deser.rs | 9 +- fuzz/src/refund_deser.rs | 8 +- lightning/src/ln/channelmanager.rs | 17 ++++ lightning/src/ln/offers_tests.rs | 2 +- lightning/src/ln/outbound_payment.rs | 6 +- lightning/src/offers/flow.rs | 42 ++------ lightning/src/offers/invoice.rs | 124 +++++++++--------------- lightning/src/offers/invoice_request.rs | 44 +-------- lightning/src/offers/refund.rs | 62 ++---------- 9 files changed, 104 insertions(+), 210 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index ba33adac0ea..f1f9ad2abb5 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -8,8 +8,11 @@ // licenses. use crate::utils::test_logger; +use bitcoin::blockdata::constants::genesis_block; +use bitcoin::Network; use bitcoin::secp256k1::{self, Keypair, Parity, PublicKey, Secp256k1, SecretKey}; use core::convert::TryFrom; +use core::time::Duration; use lightning::blinded_path::payment::{ BlindedPaymentPath, Bolt12OfferContext, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, @@ -81,6 +84,9 @@ fn privkey(byte: u8) -> SecretKey { fn build_response( invoice_request: &InvoiceRequest, secp_ctx: &Secp256k1, ) -> Result { + let network = Network::Bitcoin; + let genesis_block = genesis_block(network); + let expanded_key = ExpandedKey::new([42; 32]); let entropy_source = Randomness {}; let receive_auth_key = ReceiveAuthKey([41; 32]); @@ -145,7 +151,8 @@ fn build_response( .unwrap(); let payment_hash = PaymentHash([42; 32]); - invoice_request.respond_with(vec![payment_path], payment_hash)?.build() + let now = Duration::from_secs(genesis_block.header.time as u64); + invoice_request.respond_with(vec![payment_path], payment_hash, now)?.build() } pub fn invoice_request_deser_test(data: &[u8], out: Out) { diff --git a/fuzz/src/refund_deser.rs b/fuzz/src/refund_deser.rs index 446ac704455..753ff4d8c16 100644 --- a/fuzz/src/refund_deser.rs +++ b/fuzz/src/refund_deser.rs @@ -8,8 +8,11 @@ // licenses. use crate::utils::test_logger; +use bitcoin::blockdata::constants::genesis_block; use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1, SecretKey}; +use bitcoin::Network; use core::convert::TryFrom; +use core::time::Duration; use lightning::blinded_path::payment::{ BlindedPaymentPath, Bolt12RefundContext, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, @@ -67,6 +70,8 @@ fn privkey(byte: u8) -> SecretKey { fn build_response( refund: &Refund, signing_pubkey: PublicKey, secp_ctx: &Secp256k1, ) -> Result { + let network = Network::Bitcoin; + let genesis_block = genesis_block(network); let entropy_source = Randomness {}; let receive_auth_key = ReceiveAuthKey([41; 32]); let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); @@ -109,7 +114,8 @@ fn build_response( .unwrap(); let payment_hash = PaymentHash([42; 32]); - refund.respond_with(vec![payment_path], payment_hash, signing_pubkey)?.build() + let now = Duration::from_secs(genesis_block.header.time as u64); + refund.respond_with(vec![payment_path], payment_hash, signing_pubkey, now)?.build() } pub fn refund_deser_test(data: &[u8], out: Out) { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e3a7d3f2065..12d378c2f7f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13236,6 +13236,13 @@ where let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + #[cfg(not(feature = "std"))] + let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); + #[cfg(feature = "std")] + let created_at = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); + let builder = self.flow.create_invoice_builder_from_refund( &self.router, entropy, @@ -13245,6 +13252,7 @@ where self.create_inbound_payment(Some(amount_msats), relative_expiry, None) .map_err(|()| Bolt12SemanticError::InvalidAmount) }, + created_at, )?; let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?; @@ -15407,6 +15415,13 @@ where Err(_) => return None, }; + #[cfg(not(feature = "std"))] + let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); + #[cfg(feature = "std")] + let created_at = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); + let get_payment_info = |amount_msats, relative_expiry| { self.create_inbound_payment( Some(amount_msats), @@ -15422,6 +15437,7 @@ where &request, self.list_usable_channels(), get_payment_info, + created_at ); match result { @@ -15446,6 +15462,7 @@ where &request, self.list_usable_channels(), get_payment_info, + created_at ); match result { diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 6ef822c609a..a1b4d26a7e1 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2337,7 +2337,7 @@ fn fails_paying_invoice_with_unknown_required_features() { let invoice = match verified_invoice_request { InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { - request.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at).unwrap() + request.respond_using_derived_keys(payment_paths, payment_hash, created_at).unwrap() .features_unchecked(Bolt12InvoiceFeatures::unknown()) .build_and_sign(&secp_ctx).unwrap() }, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 75fe55bfeac..61b17a7f3bb 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -3206,7 +3206,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap() + .respond_with(payment_paths(), payment_hash(), created_at).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3253,7 +3253,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with(payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3316,7 +3316,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with(payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 36c8c63f200..cbb66ead6c8 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -932,7 +932,7 @@ where /// This is not exported to bindings users as builder patterns don't map outside of move semantics. pub fn create_invoice_builder_from_refund<'a, ES: Deref, R: Deref, F>( &'a self, router: &R, entropy_source: ES, refund: &'a Refund, - usable_channels: Vec, get_payment_info: F, + usable_channels: Vec, get_payment_info: F, created_at: Duration, ) -> Result, Bolt12SemanticError> where ES::Target: EntropySource, @@ -963,18 +963,7 @@ where ) .map_err(|_| Bolt12SemanticError::MissingPaths)?; - #[cfg(feature = "std")] let builder = refund.respond_using_derived_keys( - payment_paths, - payment_hash, - expanded_key, - entropy, - )?; - - #[cfg(not(feature = "std"))] - let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); - #[cfg(not(feature = "std"))] - let builder = refund.respond_using_derived_keys_no_std( payment_paths, payment_hash, created_at, @@ -1001,7 +990,7 @@ where /// - The [`InvoiceBuilder`] could not be created from the [`InvoiceRequest`]. pub fn create_invoice_builder_from_invoice_request_with_keys<'a, R: Deref, F>( &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, - usable_channels: Vec, get_payment_info: F, + usable_channels: Vec, get_payment_info: F, created_at: Duration, ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where R::Target: Router, @@ -1030,15 +1019,9 @@ where ) .map_err(|_| Bolt12SemanticError::MissingPaths)?; - #[cfg(feature = "std")] - let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] - let builder = invoice_request.respond_using_derived_keys_no_std( - payment_paths, - payment_hash, - Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), - ); - let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + let builder = invoice_request + .respond_using_derived_keys(payment_paths, payment_hash, created_at) + .map(|b| InvoiceBuilder::from(b).allow_mpp())?; let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); @@ -1061,7 +1044,7 @@ where /// - The [`InvoiceBuilder`] could not be created from the [`InvoiceRequest`]. pub fn create_invoice_builder_from_invoice_request_without_keys<'a, R: Deref, F>( &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, - usable_channels: Vec, get_payment_info: F, + usable_channels: Vec, get_payment_info: F, created_at: Duration, ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> where R::Target: Router, @@ -1090,16 +1073,9 @@ where ) .map_err(|_| Bolt12SemanticError::MissingPaths)?; - #[cfg(feature = "std")] - let builder = invoice_request.respond_with(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] - let builder = invoice_request.respond_with_no_std( - payment_paths, - payment_hash, - Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), - ); - - let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + let builder = invoice_request + .respond_with(payment_paths, payment_hash, created_at) + .map(|b| InvoiceBuilder::from(b).allow_mpp())?; let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index f21cd76890b..9e958a48910 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -47,18 +47,7 @@ //! // Invoice for the "offer to be paid" flow. //! # >::from( //! InvoiceRequest::try_from(bytes)? -#![cfg_attr( - feature = "std", - doc = " - .respond_with(payment_paths, payment_hash)? -" -)] -#![cfg_attr( - not(feature = "std"), - doc = " - .respond_with_no_std(payment_paths, payment_hash, core::time::Duration::from_secs(0))? -" -)] +//! .respond_with(payment_paths, payment_hash, core::time::Duration::from_secs(0))? //! # ) //! .relative_expiry(3600) //! .allow_mpp() @@ -86,18 +75,7 @@ //! # >::from( //! "lnr1qcp4256ypq" //! .parse::()? -#![cfg_attr( - feature = "std", - doc = " - .respond_with(payment_paths, payment_hash, pubkey)? -" -)] -#![cfg_attr( - not(feature = "std"), - doc = " - .respond_with_no_std(payment_paths, payment_hash, pubkey, core::time::Duration::from_secs(0))? -" -)] +//! .respond_with(payment_paths, payment_hash, pubkey, core::time::Duration::from_secs(0))? //! # ) //! .relative_expiry(3600) //! .allow_mpp() @@ -1995,7 +1973,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths.clone(), payment_hash, now) + .respond_with(payment_paths.clone(), payment_hash, now) .unwrap() .build() .unwrap(); @@ -2160,7 +2138,7 @@ mod tests { .unwrap() .build() .unwrap() - .respond_with_no_std(payment_paths.clone(), payment_hash, recipient_pubkey(), now) + .respond_with(payment_paths.clone(), payment_hash, recipient_pubkey(), now) .unwrap() .build() .unwrap() @@ -2263,7 +2241,6 @@ mod tests { } } - #[cfg(feature = "std")] #[test] fn builds_invoice_from_offer_with_expiration() { let expanded_key = ExpandedKey::new([42; 32]); @@ -2284,7 +2261,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with(payment_paths(), payment_hash()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() { @@ -2299,7 +2276,7 @@ mod tests { .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign() - .respond_with(payment_paths(), payment_hash()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() { @@ -2308,7 +2285,6 @@ mod tests { } } - #[cfg(feature = "std")] #[test] fn builds_invoice_from_refund_with_expiration() { let future_expiry = Duration::from_secs(u64::max_value()); @@ -2319,7 +2295,7 @@ mod tests { .absolute_expiry(future_expiry) .build() .unwrap() - .respond_with(payment_paths(), payment_hash(), recipient_pubkey()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() { @@ -2331,7 +2307,7 @@ mod tests { .absolute_expiry(past_expiry) .build() .unwrap() - .respond_with(payment_paths(), payment_hash(), recipient_pubkey()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() { @@ -2380,7 +2356,7 @@ mod tests { match verified_request { InvoiceRequestVerifiedFromOffer::DerivedKeys(req) => { let invoice = req - .respond_using_derived_keys_no_std(payment_paths(), payment_hash(), now()) + .respond_using_derived_keys(payment_paths(), payment_hash(), now()) .unwrap() .build_and_sign(&secp_ctx); @@ -2412,7 +2388,7 @@ mod tests { .unwrap(); if let Err(e) = refund - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -2449,7 +2425,7 @@ mod tests { .unwrap(); let invoice = refund - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -2482,7 +2458,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now) + .respond_with(payment_paths(), payment_hash(), now) .unwrap() .relative_expiry(one_hour.as_secs() as u32) .build() @@ -2503,7 +2479,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now - one_hour) + .respond_with(payment_paths(), payment_hash(), now - one_hour) .unwrap() .relative_expiry(one_hour.as_secs() as u32 - 1) .build() @@ -2535,7 +2511,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2565,7 +2541,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2585,7 +2561,7 @@ mod tests { .quantity(u64::max_value()) .unwrap() .build_unchecked_and_sign() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), @@ -2613,7 +2589,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -2669,7 +2645,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2697,7 +2673,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2715,7 +2691,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2742,7 +2718,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2819,7 +2795,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2863,7 +2839,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .relative_expiry(3600) .build() @@ -2896,7 +2872,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2940,7 +2916,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2982,7 +2958,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -3025,11 +3001,10 @@ mod tests { .build_and_sign() .unwrap(); #[cfg(not(c_bindings))] - let invoice_builder = - invoice_request.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap(); + let invoice_builder = invoice_request.respond_with(payment_paths(), payment_hash(), now()).unwrap(); #[cfg(c_bindings)] let mut invoice_builder = - invoice_request.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap(); + invoice_request.respond_with(payment_paths(), payment_hash(), now()).unwrap(); let invoice_builder = invoice_builder .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -3088,7 +3063,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3175,12 +3150,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std_using_signing_pubkey( - payment_paths(), - payment_hash(), - now(), - pubkey(46), - ) + .respond_with_using_signing_pubkey(payment_paths(), payment_hash(), now(), pubkey(46)) .unwrap() .build() .unwrap() @@ -3205,7 +3175,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std_using_signing_pubkey( + .respond_with_using_signing_pubkey( payment_paths(), payment_hash(), now(), @@ -3247,7 +3217,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3276,7 +3246,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3299,7 +3269,7 @@ mod tests { .unwrap() .build() .unwrap() - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -3340,7 +3310,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3373,7 +3343,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3416,7 +3386,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3455,7 +3425,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3501,7 +3471,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -3527,7 +3497,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3568,7 +3538,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3606,7 +3576,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3647,7 +3617,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3682,7 +3652,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3730,7 +3700,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3746,7 +3716,7 @@ mod tests { 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()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() .unwrap() @@ -3776,7 +3746,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths, payment_hash(), now) + .respond_with(payment_paths, payment_hash(), now) .unwrap() .build() .unwrap() diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index db2d649d725..263c22031f2 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -812,24 +812,6 @@ impl UnsignedInvoiceRequest { macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( $self: ident, $contents: 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. - /// - /// See [`InvoiceRequest::respond_with_no_std`] for further details where the aforementioned - /// creation time is used for the `created_at` parameter. - /// - /// [`Duration`]: core::time::Duration - #[cfg(feature = "std")] - pub fn respond_with( - &$self, payment_paths: Vec, payment_hash: PaymentHash - ) -> Result<$builder, Bolt12SemanticError> { - let created_at = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - - $contents.respond_with_no_std(payment_paths, payment_hash, created_at) - } - /// Creates an [`InvoiceBuilder`] for the request with the given required fields. /// /// Unless [`InvoiceBuilder::relative_expiry`] is set, the invoice will expire two hours after @@ -855,7 +837,7 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( /// /// [`Bolt12Invoice::created_at`]: crate::offers::invoice::Bolt12Invoice::created_at /// [`OfferBuilder::deriving_signing_pubkey`]: crate::offers::offer::OfferBuilder::deriving_signing_pubkey - pub fn respond_with_no_std( + pub fn respond_with( &$self, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration ) -> Result<$builder, Bolt12SemanticError> { @@ -873,7 +855,7 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( #[cfg(test)] #[allow(dead_code)] - pub(super) fn respond_with_no_std_using_signing_pubkey( + pub(super) fn respond_with_using_signing_pubkey( &$self, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration, signing_pubkey: PublicKey ) -> Result<$builder, Bolt12SemanticError> { @@ -1051,25 +1033,7 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( /// See [`InvoiceRequest::respond_with`] for further details. /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice - #[cfg(feature = "std")] pub fn respond_using_derived_keys( - &$self, payment_paths: Vec, payment_hash: PaymentHash - ) -> Result<$builder, Bolt12SemanticError> { - let created_at = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - - $self.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at) - } - - /// 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 - /// the same [`ExpandedKey`] as the one used to create the offer. - /// - /// See [`InvoiceRequest::respond_with_no_std`] for further details. - /// - /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice - pub fn respond_using_derived_keys_no_std( &$self, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration ) -> Result<$builder, Bolt12SemanticError> { @@ -1929,7 +1893,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -2513,7 +2477,7 @@ mod tests { .features_unchecked(InvoiceRequestFeatures::unknown()) .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with(payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnknownRequiredFeatures), diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index 99fe85e0402..456bd0267f2 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -559,27 +559,6 @@ impl Refund { } macro_rules! respond_with_explicit_signing_pubkey_methods { ($self: ident, $builder: ty) => { - /// Creates an [`InvoiceBuilder`] for the refund with the given required fields and using the - /// [`Duration`] since [`std::time::SystemTime::UNIX_EPOCH`] as the creation time. - /// - /// See [`Refund::respond_with_no_std`] for further details where the aforementioned creation - /// time is used for the `created_at` parameter. - /// - /// This is not exported to bindings users as builder patterns don't map outside of move semantics. - /// - /// [`Duration`]: core::time::Duration - #[cfg(feature = "std")] - pub fn respond_with( - &$self, payment_paths: Vec, payment_hash: PaymentHash, - signing_pubkey: PublicKey, - ) -> Result<$builder, Bolt12SemanticError> { - let created_at = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - - $self.respond_with_no_std(payment_paths, payment_hash, signing_pubkey, created_at) - } - /// Creates an [`InvoiceBuilder`] for the refund with the given required fields. /// /// Unless [`InvoiceBuilder::relative_expiry`] is set, the invoice will expire two hours after @@ -602,7 +581,7 @@ macro_rules! respond_with_explicit_signing_pubkey_methods { ($self: ident, $buil /// This is not exported to bindings users as builder patterns don't map outside of move semantics. /// /// [`Bolt12Invoice::created_at`]: crate::offers::invoice::Bolt12Invoice::created_at - pub fn respond_with_no_std( + pub fn respond_with( &$self, payment_paths: Vec, payment_hash: PaymentHash, signing_pubkey: PublicKey, created_at: Duration ) -> Result<$builder, Bolt12SemanticError> { @@ -623,32 +602,7 @@ macro_rules! respond_with_derived_signing_pubkey_methods { ($self: ident, $build /// This is not exported to bindings users as builder patterns don't map outside of move semantics. /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice - #[cfg(feature = "std")] pub fn respond_using_derived_keys( - &$self, payment_paths: Vec, payment_hash: PaymentHash, - expanded_key: &ExpandedKey, entropy_source: ES - ) -> Result<$builder, Bolt12SemanticError> - where - ES::Target: EntropySource, - { - let created_at = std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - - $self.respond_using_derived_keys_no_std( - payment_paths, payment_hash, created_at, expanded_key, entropy_source - ) - } - - /// Creates an [`InvoiceBuilder`] for the refund using the given required fields and that uses - /// derived signing keys to sign the [`Bolt12Invoice`]. - /// - /// See [`Refund::respond_with_no_std`] for further details. - /// - /// This is not exported to bindings users as builder patterns don't map outside of move semantics. - /// - /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice - pub fn respond_using_derived_keys_no_std( &$self, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration, expanded_key: &ExpandedKey, entropy_source: ES ) -> Result<$builder, Bolt12SemanticError> @@ -1201,7 +1155,7 @@ mod tests { // Fails verification with altered fields let invoice = refund - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .experimental_baz(42) .build() @@ -1224,7 +1178,7 @@ mod tests { let invoice = Refund::try_from(encoded_refund) .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() .unwrap() @@ -1242,7 +1196,7 @@ mod tests { let invoice = Refund::try_from(encoded_refund) .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() .unwrap() @@ -1286,7 +1240,7 @@ mod tests { assert_ne!(refund.payer_signing_pubkey(), node_id); let invoice = refund - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .experimental_baz(42) .build() @@ -1307,7 +1261,7 @@ mod tests { let invoice = Refund::try_from(encoded_refund) .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() .unwrap() @@ -1327,7 +1281,7 @@ mod tests { let invoice = Refund::try_from(encoded_refund) .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) .unwrap() .build() .unwrap() @@ -1512,7 +1466,7 @@ mod tests { .features_unchecked(InvoiceRequestFeatures::unknown()) .build() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .respond_with(payment_paths(), payment_hash(), recipient_pubkey(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnknownRequiredFeatures), From b4c5ad15487e291b42cd04480f15ab6b9703be18 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 24 Nov 2025 19:27:08 +0530 Subject: [PATCH 7/8] Introduce recurrence handling in InvoiceRequest flow (payee side) This commit adds payee-side handling for recurrence-enabled `InvoiceRequest`s. The logic now: - Distinguishes between one-off requests, initial recurring requests, and successive recurring requests. - Initializes a new `RecurrenceData` session on the first recurring request (counter = 0). - Validates successive requests against stored session state (offset, expected counter, basetime). - Enforces paywindow timing when applicable. - Handles recurrence cancellation by removing the session and returning no invoice. This forms the core stateful logic required for a node to act as a BOLT12 recurrence payee. Payment-acceptance and state-update logic will follow in the next commit. --- lightning/src/ln/channelmanager.rs | 92 ++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 12d378c2f7f..49b6e7c5855 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -15403,7 +15403,7 @@ where None => return None, }; - let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { + let verified_invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { self.pending_events.lock().unwrap().push_back((Event::StaticInvoiceRequested { @@ -15414,6 +15414,7 @@ where }, Err(_) => return None, }; + let invoice_request = verified_invoice_request.inner(); #[cfg(not(feature = "std"))] let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); @@ -15422,6 +15423,82 @@ where .duration_since(std::time::SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); + // Recurrence checks + let recurrence_basetime = if let Some(recurrence_fields) = invoice_request.recurrence_fields() { + let payer_id = invoice_request.payer_signing_pubkey(); + let mut sessions = self.active_recurrence_sessions.lock().unwrap(); + + // We first categorise the invoice request based on it's type. + let recurrence_counter = invoice_request.recurrence_counter(); + let recurrence_cancel = invoice_request.recurrence_cancel(); + let existing_session = sessions.get(&payer_id); + + match (existing_session, recurrence_counter, recurrence_cancel) { + // This represents case where the payer, didn't support recurrence + // but we set recurrence optional so we allow payer to pay one-off + (None, None, None) => { None }, + // It's the first invoice request in recurrence series + (None, Some(0), None) => { + let recurrence_basetime = recurrence_fields + .recurrence_base + .map(|base| base.basetime) + .unwrap_or(created_at.as_secs()); + + // Next we prepare recurrence_data to be stored in our recurrence session + let recurrence_data = RecurrenceData { + invoice_request_start: invoice_request.recurrence_start(), + next_payable_counter: 0, + recurrence_basetime, + }; + // Now we store it in our active_recurrence_session + sessions.insert(payer_id, recurrence_data); + + Some(recurrence_basetime) + + }, + // it's a successive invoice request in recurrence series + (Some(data), Some(counter), None) if counter > 0 => { + // We confirm all the data to ensure this is an expected successive invoice request + if data.invoice_request_start != invoice_request.recurrence_start() + || data.next_payable_counter != counter + { + return None + } + + // Next we ensure that the successive invoice_request is received between the period's paywindow + if let Some(window) = recurrence_fields.recurrence_paywindow { + let period_index = data.invoice_request_start.unwrap_or(0) + counter; + + let period_start = data.recurrence_basetime + + period_index as u64 * recurrence_fields.recurrence.period_length_secs().unwrap(); + + if created_at.as_secs() < period_start - window.seconds_before as u64 + || created_at.as_secs() >= period_start + window.seconds_after as u64 + { + return None + } + } + + Some(data.recurrence_basetime) + }, + // it's a cancel recurrence invoice request + (Some(_data), Some(counter), Some(())) if counter > 0 => { + // Here we simply remove the data from our sessions + sessions.remove(&payer_id); + + // And since cancellation invoice request are stub invoice request, + // we don't respond to this invoice request + return None + }, + _ => { + debug_assert!(false, "Should be unreachable, as all the invalid cases are handled during parsing"); + return None + } + } + } else { + None + }; + let get_payment_info = |amount_msats, relative_expiry| { self.create_inbound_payment( Some(amount_msats), @@ -15430,7 +15507,7 @@ where ).map_err(|_| Bolt12SemanticError::InvalidAmount) }; - let (result, context) = match invoice_request { + let (result, context) = match verified_invoice_request { InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { let result = self.flow.create_invoice_builder_from_invoice_request_with_keys( &self.router, @@ -15441,7 +15518,11 @@ where ); match result { - Ok((builder, context)) => { + Ok((mut builder, context)) => { + recurrence_basetime.map(|basetime| + builder.set_invoice_recurrence_basetime(basetime) + ); + let res = builder .build_and_sign(&self.secp_ctx) .map_err(InvoiceError::from); @@ -15466,7 +15547,10 @@ where ); match result { - Ok((builder, context)) => { + Ok((mut builder, context)) => { + recurrence_basetime.map(|basetime| + builder.set_invoice_recurrence_basetime(basetime) + ); let res = builder .build() .map_err(InvoiceError::from) From 623369769048dd279df63d4b6b3e92b9f2def155 Mon Sep 17 00:00:00 2001 From: shaavan Date: Tue, 25 Nov 2025 20:02:03 +0530 Subject: [PATCH 8/8] Introduce recurrence state-update logic This commit adds the final piece of the payee-side recurrence flow: updating the internal `next_payable_counter` once a recurring payment has been successfully claimed. The update is performed immediately before emitting the `PaymentClaimed` event, ensuring the counter is advanced only after the payment is fully completed and acknowledged by the node. This provides a clear correctness boundary and avoids premature state transitions. The approach is intentionally conservative for this PoC. Future refinements may place the update earlier in the pipeline or integrate it more tightly with the payment-claim flow, but the current design offers simple and reliable semantics. --- lightning/src/ln/channelmanager.rs | 32 ++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 49b6e7c5855..5aeb0fe9aa2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -50,7 +50,7 @@ use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch}; use crate::events::{ self, ClosureReason, Event, EventHandler, EventsProvider, HTLCHandlingFailureType, - InboundChannelFunds, PaymentFailureReason, ReplayEvent, + InboundChannelFunds, PaymentFailureReason, PaymentPurpose, ReplayEvent, }; use crate::events::{FundingInfo, PaidBolt12Invoice}; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; @@ -93,7 +93,9 @@ use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; -use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; +use crate::offers::invoice_request::{ + InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer, +}; use crate::offers::nonce::Nonce; use crate::offers::offer::{Offer, OfferFromHrn, RecurrenceData, RecurrenceFields}; use crate::offers::parse::Bolt12SemanticError; @@ -9514,6 +9516,32 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ payment_id, durable_preimage_channel, }) = payment { + // At this point, the payment has been successfully claimed. If it belongs + // to a recurring offer, we can safely advance the recurrence state. + + match &purpose { + PaymentPurpose::Bolt12OfferPayment { + payment_context: Bolt12OfferContext { + invoice_request: InvoiceRequestFields { + payer_signing_pubkey, + recurrence_counter: Some(paid_counter), + .. + }, + .. + }, + .. + } => { + let mut sessions = self.active_recurrence_sessions.lock().unwrap(); + + if let Some(data) = sessions.get_mut(payer_signing_pubkey) { + if data.next_payable_counter == *paid_counter { + data.next_payable_counter += 1; + } + } + }, + _ => {} + } + let event = events::Event::PaymentClaimed { payment_hash, purpose,