diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..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]); @@ -98,6 +104,7 @@ fn build_response( .payer_note() .map(|s| UntrustedString(s.to_string())), human_readable_name: None, + recurrence_counter: None, } }; @@ -144,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 399c51b9d9a..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,9 +93,11 @@ 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}; +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 +2673,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 +3976,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()), @@ -9498,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, @@ -12777,6 +12821,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. /// @@ -13194,6 +13264,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, @@ -13203,6 +13280,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)?; @@ -15353,7 +15431,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 { @@ -15364,6 +15442,90 @@ 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); + #[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"); + + // 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( @@ -15373,17 +15535,22 @@ 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, &request, self.list_usable_channels(), get_payment_info, + created_at ); 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); @@ -15404,10 +15571,14 @@ where &request, self.list_usable_channels(), get_payment_info, + created_at ); 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) @@ -17259,6 +17430,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), @@ -17277,6 +17449,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); @@ -18171,6 +18344,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/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 4c53aefe58d..a1b4d26a7e1 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); @@ -2331,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 94a4534c61a..cbb66ead6c8 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) }) } @@ -894,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, @@ -925,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, @@ -963,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, @@ -992,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 }); @@ -1023,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, @@ -1052,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 6dfd6eac508..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() @@ -422,6 +400,7 @@ macro_rules! invoice_builder_methods { fallbacks: None, features: Bolt12InvoiceFeatures::empty(), signing_pubkey, + invoice_recurrence_basetime: None, #[cfg(test)] experimental_baz: None, } @@ -438,6 +417,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 +775,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 +1434,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 +1516,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 +1708,7 @@ impl TryFrom for InvoiceContents { features, node_id, message_paths, + invoice_recurrence_basetime, }, experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream, @@ -1713,6 +1748,7 @@ impl TryFrom for InvoiceContents { fallbacks, features, signing_pubkey, + invoice_recurrence_basetime, #[cfg(test)] experimental_baz, }; @@ -1720,6 +1756,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 +1783,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); @@ -1877,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(); @@ -1987,6 +2083,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, @@ -1997,6 +2098,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( @@ -2011,6 +2115,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 }, @@ -2033,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() @@ -2090,6 +2195,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, @@ -2100,6 +2210,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( @@ -2114,6 +2227,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 }, @@ -2127,7 +2241,6 @@ mod tests { } } - #[cfg(feature = "std")] #[test] fn builds_invoice_from_offer_with_expiration() { let expanded_key = ExpandedKey::new([42; 32]); @@ -2148,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() { @@ -2163,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() { @@ -2172,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()); @@ -2183,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() { @@ -2195,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() { @@ -2244,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); @@ -2276,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(), @@ -2313,7 +2425,7 @@ mod tests { .unwrap(); let invoice = refund - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -2346,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() @@ -2367,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() @@ -2399,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() @@ -2429,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() @@ -2449,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), @@ -2477,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()) @@ -2533,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() @@ -2561,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() @@ -2579,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() @@ -2606,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() @@ -2683,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() @@ -2727,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() @@ -2760,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() @@ -2804,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() @@ -2846,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() @@ -2889,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()) @@ -2952,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() @@ -3039,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() @@ -3069,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(), @@ -3111,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() @@ -3140,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() @@ -3163,7 +3269,7 @@ mod tests { .unwrap() .build() .unwrap() - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -3204,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() @@ -3237,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() @@ -3280,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(); @@ -3319,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(); @@ -3365,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() @@ -3391,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(); @@ -3432,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(); @@ -3470,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() @@ -3511,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() @@ -3546,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() @@ -3594,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() @@ -3610,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() @@ -3640,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 4311d194dca..263c22031f2 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> { @@ -757,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 @@ -800,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> { @@ -818,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> { @@ -996,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> { @@ -1050,6 +1069,7 @@ macro_rules! fields_accessor { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, + recurrence_counter, .. }, } = &$inner; @@ -1063,6 +1083,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 +1188,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 +1255,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 +1318,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 +1473,9 @@ impl TryFrom for InvoiceRequestContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { @@ -1470,6 +1512,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 +1618,9 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, #[cfg(test)] experimental_bar, }, @@ -1505,6 +1646,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 +1676,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 +1689,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 +1697,7 @@ impl Readable for InvoiceRequestFields { quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + recurrence_counter, }) } } @@ -1647,6 +1804,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, @@ -1657,6 +1819,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 }, @@ -1728,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() @@ -2312,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), @@ -3112,6 +3277,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/offer.rs b/lightning/src/offers/offer.rs index 7ad3c282c77..969dbb9198e 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, }, @@ -389,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`]. /// @@ -632,10 +644,315 @@ 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)) + } +} + +/// 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: +/// 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 +1025,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 +1315,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 +1386,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 +1415,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 +1574,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 +1675,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 +1726,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 +1772,7 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, + recurrence_fields, #[cfg(test)] experimental_foo, }) @@ -1429,6 +1850,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 +1867,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..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> @@ -789,6 +743,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 = { @@ -808,6 +767,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 { @@ -918,6 +880,11 @@ impl TryFrom for RefundContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, InvoiceRequestTlvStream { chain, @@ -928,6 +895,9 @@ impl TryFrom for RefundContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -979,11 +949,25 @@ 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); } + 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 => { @@ -1108,6 +1092,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, @@ -1118,6 +1107,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 }, @@ -1163,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() @@ -1186,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() @@ -1204,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() @@ -1248,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() @@ -1269,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() @@ -1289,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() @@ -1474,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), diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index 77f486a6a06..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, @@ -908,6 +915,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( @@ -922,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 },