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..0dec6fb5c54 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -20,6 +20,7 @@ use bitcoin::block::Header; use bitcoin::constants::ChainHash; use bitcoin::key::constants::SECRET_KEY_SIZE; +use bitcoin::key::Keypair; use bitcoin::network::Network; use bitcoin::transaction::Transaction; @@ -50,7 +51,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 +94,14 @@ 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, UnsignedInvoiceRequest, +}; use crate::offers::nonce::Nonce; -use crate::offers::offer::{Offer, OfferFromHrn}; +use crate::offers::offer::{ + InboundRecurrenceSessionData, Offer, OfferFromHrn, OutboundRecurrenceSessionData, + RecurrenceFields, +}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::Refund; use crate::offers::static_invoice::StaticInvoice; @@ -627,6 +633,35 @@ impl Readable for PaymentId { } } +/// A user-provided identifier used to uniquely identify an outbound recurrence session. +/// +/// The same `RecurrenceId` must be supplied across successive calls to +/// `ChannelManager::pay_for_recurrence` in order to continue a given recurrence +/// session and maintain a stable payer identity. +/// +/// This type is not exported to bindings users; bindings APIs use a raw `[u8; 32]` +/// representation directly. +#[derive(Hash, Copy, Clone, PartialEq, Eq)] +pub struct RecurrenceId(pub [u8; Self::LENGTH]); + +impl RecurrenceId { + /// Number of bytes in the id. + pub const LENGTH: usize = 32; +} + +impl Writeable for RecurrenceId { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.0.write(w) + } +} + +impl Readable for RecurrenceId { + fn read(r: &mut R) -> Result { + let buf: [u8; 32] = Readable::read(r)?; + Ok(RecurrenceId(buf)) + } +} + /// An identifier used to uniquely identify an intercepted HTLC to LDK. /// /// This is not exported to bindings users as we just use [u8; 32] directly @@ -2671,6 +2706,24 @@ pub struct ChannelManager< #[cfg(not(test))] flow: OffersMessageFlow, + /// Tracks all active inbound recurrence sessions for this node. + /// + /// Each entry is keyed by the payer’s `payer_signing_pubkey` from the + /// initial `invoice_request`. The associated `InboundRecurrenceSessionData` 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_inbound_recurrence_sessions: Mutex>, + + /// Tracks all the active outbound recurrence session for this node. + active_outbound_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 +4013,9 @@ where router, flow, + active_inbound_recurrence_sessions: Mutex::new(new_hash_map()), + active_outbound_recurrence_sessions: Mutex::new(new_hash_map()), + best_block: RwLock::new(params.best_block), outbound_scid_aliases: Mutex::new(new_hash_set()), @@ -8354,6 +8410,155 @@ where should_persist = NotifyOption::DoPersist; } + #[cfg(not(feature = "std"))] + let current_time = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); + #[cfg(feature = "std")] + let current_time = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); + + // ----------------------------------------------------------------------------- + // Outbound Recurrence session cleanup + // + // We prune active outbound recurrence sessions that are no longer payable. + // A session becomes invalid if: + // 1. Its recurrence limit has been reached, OR + // 2. The payer has missed the allowed payment window for the current period. + // + // Sessions that have not yet received their first invoice are kept alive. + // ----------------------------------------------------------------------------- + { + let mut sessions = self.active_outbound_recurrence_sessions.lock().unwrap(); + + // `retain` keeps entries for which the closure returns `true` + sessions.retain(|_, data| { + match (data.invoice_recurrence_basetime, data.next_trigger_time) { + // ----------------------------------------------------------------- + // Case A: Waiting for the first invoice + // + // No invoice basetime and no trigger time implies that the recurrence + // session has been created but no invoice has been received yet. + // We cannot invalidate the session at this stage. + // ----------------------------------------------------------------- + (None, None) => true, + + // ----------------------------------------------------------------- + // Case B: Active recurrence with known basetime and trigger + // ----------------------------------------------------------------- + (Some(recurrence_basetime), Some(trigger_time)) => { + // Active recurrence sessions must always correspond to offers + // that actually define recurrence fields. If this invariant is + // violated, the session is considered invalid. + let fields = match data.offer.recurrence_fields() { + Some(fields) => fields, + None => { + debug_assert!( + false, + "Active recurrence session without recurrence fields, shouldn't be possible." + ); + return false; + }, + }; + + // ------------------------------------------------------------- + // 1. Recurrence limit check + // + // If the next recurrence counter has reached the offer-defined + // recurrence limit, the recurrence sequence is complete and + // the session must be removed. + // ------------------------------------------------------------- + if let Some(limit) = fields.recurrence_limit { + if data.next_recurrence_counter >= limit.0 { + return false; + } + } + + // ------------------------------------------------------------- + // 2. Paywindow expiry check + // + // Each recurrence period defines a payable window starting from + // the period's start time. If the next trigger occurs after the + // end of this window, the period went unpaid and the recurrence + // session becomes void. + // + // If no explicit paywindow is defined, the full recurrence + // period length is treated as the payable window. + // ------------------------------------------------------------- + let paywindow_secs = fields + .recurrence_paywindow + .map(|window| window.seconds_after as u64) + .unwrap_or(fields.recurrence.period_length_secs()); + + let window_end = fields + .recurrence + .start_time(recurrence_basetime, data.next_recurrence_counter) + + paywindow_secs; + + // Keep the session only if the trigger is still within + // the payable window. + trigger_time <= window_end + }, + + // ----------------------------------------------------------------- + // Any other combination of basetime/trigger is invalid by design + // ----------------------------------------------------------------- + _ => { + debug_assert!( + false, + "Invalid recurrence session state: inconsistent basetime/trigger" + ); + false + }, + } + }); + } + + { + let mut inbound_sessions = self.active_inbound_recurrence_sessions.lock().unwrap(); + + // ----------------------------------------------------------------------------- + // Inbound Recurrence session cleanup + // + // We prune active inbound recurrence sessions that can no longer accept + // valid recurrence-enabled `invoice_request`s. + // + // A session becomes invalid if: + // 1. Its recurrence limit has been reached, OR + // 2. The valid time window for receiving the next `invoice_request` + // has elapsed without a valid request being received. + // ----------------------------------------------------------------------------- + inbound_sessions.retain(|_, data| { + // ------------------------------------------------------------- + // 1. Recurrence limit check + // + // If the next expected recurrence counter has reached the + // offer-defined recurrence limit, the recurrence sequence is + // complete and the session must be removed. + // ------------------------------------------------------------- + if let Some(limit) = data.recurrence_fields.recurrence_limit { + if data.next_payable_counter >= limit.0 { + return false; + } + } + + // ------------------------------------------------------------- + // 2. Invoice request window expiry check + // + // Each recurrence period defines a valid time window during which + // the next recurrence-enabled `invoice_request` may be received. + // + // If the current time has passed the end of this window without + // receiving a valid request, the period is considered missed and + // the recurrence session becomes void. + // ------------------------------------------------------------- + if current_time.as_secs() >= data.next_invoice_request_window.1 { + return false; + } + + true + }); + } + should_persist }); } @@ -9498,6 +9703,44 @@ 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_inbound_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; + + // Update the next_invoice_request_paywindow + let seconds_before = data.recurrence_fields + .recurrence_paywindow.map(|window| window.seconds_before as u64) + .unwrap_or(data.recurrence_fields.recurrence.period_length_secs()); + + let seconds_after = data.recurrence_fields + .recurrence_paywindow.map(|window| window.seconds_after as u64) + .unwrap_or(data.recurrence_fields.recurrence.period_length_secs()); + + let new_start_time = data.recurrence_fields.recurrence.start_time(data.recurrence_basetime, data.next_payable_counter); + data.next_invoice_request_window = (new_start_time.saturating_sub(seconds_before), new_start_time.saturating_add(seconds_after)); + } + } + }, + _ => {} + } + let event = events::Event::PaymentClaimed { payment_hash, purpose, @@ -12777,6 +13020,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. /// @@ -13022,6 +13291,14 @@ where &self, offer: &Offer, amount_msats: Option, payment_id: PaymentId, optional_params: OptionalOfferPaymentParams, ) -> Result<(), Bolt12SemanticError> { + if offer.recurrence_fields().is_some() { + debug_assert!( + false, + "Offer contains recurrence. Use pay_for_offer_with_recurrence instead" + ); + return Err(Bolt12SemanticError::InvalidRecurrence); + } + let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { self.pending_outbound_payments .add_new_awaiting_invoice( @@ -13041,16 +13318,288 @@ where optional_params.payer_note, payment_id, None, + None, + None, create_pending_payment_fn, ) } + /// Initiates a BOLT12 recurrence by sending the primary invoice request. + /// + /// This method starts a new outbound recurrence session for the given + /// `Offer`. It sends the first (counter = 0) `InvoiceRequest` and records + /// the necessary recurrence context so that subsequent payments can be + /// continued via [`pay_for_recurrence`]. + /// + /// Unlike [`pay_for_offer`], this call establishes long-lived payer-side + /// state. The provided `recurrence_id` uniquely identifies the recurrence + /// session and is later used to continue or cancel it. + /// + /// The optional `recurrence_start` specifies the starting period index + /// for the recurrence and must satisfy the constraints imposed by the + /// Offer’s recurrence fields: + /// - If the Offer defines a recurrence base time, `recurrence_start` + /// must be provided. + /// - If the Offer does not define a recurrence base, `recurrence_start` + /// must be omitted. + /// + /// Returns an error if the Offer is not recurrence-enabled, the supplied + /// recurrence parameters are invalid, or the primary invoice request + /// cannot be constructed or enqueued. + /// + /// On success, an outbound recurrence session is created and persisted, + /// and the recurrence may be continued using `pay_for_recurrence` or + /// explicitly terminated using `cancel_recurrence`. + pub fn pay_for_offer_with_recurrence( + &self, offer: &Offer, amount_msats: Option, payment_id: PaymentId, + recurrence_id: RecurrenceId, optional_params: OptionalOfferPaymentParams, + recurrence_start: Option, + ) -> Result<(), Bolt12SemanticError> { + let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { + self.pending_outbound_payments + .add_new_awaiting_invoice( + payment_id, + StaleExpiration::TimerTicks(1), + optional_params.retry_strategy, + optional_params.route_params_config, + Some(retryable_invoice_request), + ) + .map_err(|_| Bolt12SemanticError::DuplicatePaymentId) + }; + + self.pay_for_offer_intern( + offer, + if offer.expects_quantity() { Some(1) } else { None }, + amount_msats, + optional_params.payer_note, + payment_id, + Some(recurrence_id), + None, + recurrence_start, + create_pending_payment_fn, + ) + } + + /// Continues an active BOLT12 recurrence by sending the next invoice request. + /// + /// Unlike [`pay_for_offer_with_recurrence`], which initiates a one-off payment + /// (or the primary request of a recurrence), this method operates on an existing + /// outbound recurrence session identified by `recurrence_id`. + /// + /// The payer reuses the stored recurrence context to: + /// - derive a stable payer signing key, + /// - populate the next expected recurrence counter, + /// - and construct a correctly-linked `InvoiceRequest` for the next period. + /// + /// This API enforces recurrence continuity by requiring an existing + /// recurrence session and rejecting attempts to pay unknown or expired + /// recurrences. + /// + /// Returns an error if the recurrence does not exist, the payment ID is + /// duplicated, or the invoice request cannot be constructed or enqueued. + pub fn pay_for_recurrence( + &self, recurrence_id: RecurrenceId, amount_msats: Option, payment_id: PaymentId, + optional_params: OptionalOfferPaymentParams, + ) -> Result<(), Bolt12SemanticError> { + let sessions = self.active_outbound_recurrence_sessions.lock().unwrap(); + + let data = match sessions.get(&recurrence_id) { + None => return Err(Bolt12SemanticError::InvalidRecurrence), + Some(data) => data, + }; + + let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { + self.pending_outbound_payments + .add_new_awaiting_invoice( + payment_id, + StaleExpiration::TimerTicks(1), + optional_params.retry_strategy, + optional_params.route_params_config, + Some(retryable_invoice_request), + ) + .map_err(|_| Bolt12SemanticError::DuplicatePaymentId) + }; + + let entropy = &*self.entropy_source; + let nonce = Nonce::from_entropy_source(entropy); + let expanded_key = &self.inbound_payment_key; + let secp_ctx = &self.secp_ctx; + + let offer = &data.offer; + let quantity = if offer.expects_quantity() { Some(1) } else { None }; + + let keys = { + let mut seed = expanded_key.hmac_for_offer(); + seed.input(&recurrence_id.0); + + let hmac = Hmac::from_engine(seed); + let privkey = SecretKey::from_slice(hmac.as_byte_array()).unwrap(); + Keypair::from_secret_key(secp_ctx, &privkey) + }; + + debug_assert_eq!( + keys.public_key(), + data.payer_signing_pubkey, + "Derived KeyPair does not match the payer signing public key" + ); + + let builder = offer.request_invoice_with_explicit_signing_pubkey( + keys.public_key(), + expanded_key, + nonce, + secp_ctx, + payment_id, + )?; + + let builder = builder.chain_hash(self.chain_hash)?; + let builder = builder.recurrence_counter(data.next_recurrence_counter); + + let builder = match data.recurrence_start { + None => builder, + Some(start) => builder.recurrence_start(start), + }; + + let builder = match quantity { + None => builder, + Some(quantity) => builder.quantity(quantity)?, + }; + let builder = match amount_msats { + None => builder, + Some(amount_msats) => builder.amount_msats(amount_msats)?, + }; + let builder = match optional_params.payer_note { + None => builder, + Some(payer_note) => builder.payer_note(payer_note), + }; + + let invoice_request = builder + .build()? + .sign(|message: &UnsignedInvoiceRequest| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys)) + }) + .expect("failed verifying signature"); + + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + + self.flow.enqueue_invoice_request( + invoice_request.clone(), + payment_id, + nonce, + self.get_peers_for_blinded_path(), + )?; + + let retryable_invoice_request = RetryableInvoiceRequest { + invoice_request: invoice_request.clone(), + nonce, + needs_retry: true, + }; + + create_pending_payment_fn(retryable_invoice_request) + } + + /// Explicitly cancels an active BOLT12 recurrence session. + /// + /// If payer wish to stop a recurrence, they must actively signal + /// cancellation to the payee so that no further recurrence-enabled + /// invoices are issued or accepted. + /// + /// This method constructs and sends a final `InvoiceRequest` carrying a + /// recurrence cancellation signal, derived from the existing outbound + /// recurrence session identified by `recurrence_id`. + /// + /// Cancellation is treated as an explicit protocol action rather than a + /// local state change. Once the cancellation request is enqueued, the + /// corresponding outbound recurrence session is removed, ensuring that no + /// further payments can be initiated for that recurrence. + /// + /// Returns an error if the recurrence does not exist or the cancellation + /// request cannot be constructed or enqueued. + pub fn cancel_recurrence( + &self, recurrence_id: RecurrenceId, + ) -> Result<(), Bolt12SemanticError> { + let mut sessions = self.active_outbound_recurrence_sessions.lock().unwrap(); + + let data = match sessions.get(&recurrence_id) { + None => return Err(Bolt12SemanticError::InvalidRecurrence), + Some(data) => data, + }; + + let entropy = &*self.entropy_source; + let nonce = Nonce::from_entropy_source(entropy); + let expanded_key = &self.inbound_payment_key; + let secp_ctx = &self.secp_ctx; + + let offer = &data.offer; + + let keys = { + let mut seed = expanded_key.hmac_for_offer(); + seed.input(&recurrence_id.0); + + let hmac = Hmac::from_engine(seed); + let privkey = SecretKey::from_slice(hmac.as_byte_array()).unwrap(); + Keypair::from_secret_key(secp_ctx, &privkey) + }; + + debug_assert_eq!( + keys.public_key(), + data.payer_signing_pubkey, + "Derived KeyPair does not match the payer signing public key" + ); + + // Create a Dummy Payment ID, we don't accept a response back + let payment_id = PaymentId([1; 32]); + + let builder = offer.request_invoice_with_explicit_signing_pubkey( + keys.public_key(), + expanded_key, + nonce, + secp_ctx, + payment_id, + )?; + let builder = builder.chain_hash(self.chain_hash)?; + + // Spec commentary: Spec is not clear about when sending cancel invoice request + // do we need to set the appropriate recurrence_counter, and recurrence_start. + // We decide to be conservative here, by still setting them appropriately. + let builder = builder.recurrence_counter(data.next_recurrence_counter); + + let builder = match data.recurrence_start { + None => builder, + Some(start) => builder.recurrence_start(start), + }; + + let builder = builder.recurrence_cancel(); + + let invoice_request = builder.build_and_sign()?; + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + + self.flow.enqueue_invoice_request( + invoice_request.clone(), + payment_id, + nonce, + self.get_peers_for_blinded_path(), + )?; + + // Ones we enqueue the invoice request, we remove the data from sessions + sessions.remove(&recurrence_id); + + Ok(()) + } + /// Pays for an [`Offer`] which was built by resolving a human readable name. It is otherwise /// identical to [`Self::pay_for_offer`]. pub fn pay_for_offer_from_hrn( &self, offer: &OfferFromHrn, amount_msats: u64, payment_id: PaymentId, optional_params: OptionalOfferPaymentParams, ) -> Result<(), Bolt12SemanticError> { + if offer.offer.recurrence_fields().is_some() { + debug_assert!( + false, + "Offer contains recurrence. Use pay_for_offer_with_recurrence instead" + ); + return Err(Bolt12SemanticError::InvalidRecurrence); + } + let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { self.pending_outbound_payments .add_new_awaiting_invoice( @@ -13069,7 +13618,9 @@ where Some(amount_msats), optional_params.payer_note, payment_id, + None, Some(offer.hrn), + None, create_pending_payment_fn, ) } @@ -13093,6 +13644,14 @@ where &self, offer: &Offer, amount_msats: Option, payment_id: PaymentId, optional_params: OptionalOfferPaymentParams, quantity: u64, ) -> Result<(), Bolt12SemanticError> { + if offer.recurrence_fields().is_some() { + debug_assert!( + false, + "Offer contains recurrence. Use pay_for_offer_with_recurrence instead" + ); + return Err(Bolt12SemanticError::InvalidRecurrence); + } + let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { self.pending_outbound_payments .add_new_awaiting_invoice( @@ -13112,6 +13671,8 @@ where optional_params.payer_note, payment_id, None, + None, + None, create_pending_payment_fn, ) } @@ -13119,15 +13680,127 @@ where #[rustfmt::skip] fn pay_for_offer_intern Result<(), Bolt12SemanticError>>( &self, offer: &Offer, quantity: Option, amount_msats: Option, - payer_note: Option, payment_id: PaymentId, - human_readable_name: Option, create_pending_payment: CPP, + payer_note: Option, payment_id: PaymentId, recurrence_id: Option, + human_readable_name: Option, + recurrence_start: Option, create_pending_payment: CPP, ) -> Result<(), Bolt12SemanticError> { let entropy = &*self.entropy_source; let nonce = Nonce::from_entropy_source(entropy); - let builder = self.flow.create_invoice_request_builder( - offer, nonce, payment_id, - )?; + let expanded_key = &self.inbound_payment_key; + let secp_ctx = &self.secp_ctx; + + let (keys_opt, builder) = match (recurrence_id, offer.recurrence_fields()) { + (Some(id), Some(fields)) => { + let recurrence_basetime = fields.recurrence_base; + + // Validate user-supplied recurrence_start against the offer's recurrence requirements. + match (recurrence_basetime, recurrence_start) { + // Offer defines a recurrence_base → caller must provide a recurrence_start. + (Some(_), None) => { + return Err(Bolt12SemanticError::InvalidRecurrence) + } + // Offer is not recurrent → caller must *not* provide recurrence_start. + (None, Some(_)) => { + return Err(Bolt12SemanticError::InvalidRecurrence) + } + _ => {} + } + + // Compute the base timestamp and the next trigger time for the recurrence session. + // + // If the offer defines a recurrence_base, we have enough information to compute + // the invoice recurrence basetime and the next trigger time immediately. + // + // If the offer does not define a recurrence_base, we must wait until the first + // invoice corresponding to this recurrence is received, as the + // invoice_recurrence_basetime is derived from that invoice. + // + // Note: + // We choose to create the OutboundRecurrenceSessionData at this point even if + // some recurrence fields might not be yet known. This is necessary because we must + // persist the original offer in the session data in order to create successive + // invoice requests. + // + // In the payer flow, this is the only point at which we have access to the + // complete original offer. + let (invoice_recurrence_basetime, next_trigger_time) = + if let Some(base) = recurrence_basetime { + let basetime = base.basetime; + let start = recurrence_start.expect("Checked presence earlier"); + let period = fields.recurrence.period_length_secs(); + + let next_trigger = + basetime + (start as u64).saturating_mul(period); + + (Some(basetime), Some(next_trigger)) + } else { + (None, None) + }; + + // The keypair used to sign the primary invoice request of the recurrence. + // + // WARNING: + // This key derivation provides minimal security. It relies solely on the + // user-provided recurrence_id as its entropy source and exists only to ensure + // that the payer_signing_pubkey remains stable across all invoice requests + // belonging to the same recurrence. + // + // This approach is only for a proof of concept, but MUST NOT be used in + // a production-ready design. + // + // In later phases, this may be replaced by: + // 1. A stateful approach where recurrence keys are managed by the NodeSigner + // implementation, or + // 2. A stateless derivation scheme where the original signing keys can be + // deterministically rebuilt from stable but secret recurrence inputs. + let keys = { + let mut seed = expanded_key.hmac_for_offer(); + seed.input(&id.0); + + let hmac = Hmac::from_engine(seed); + let privkey = SecretKey::from_slice(hmac.as_byte_array()).unwrap(); + Keypair::from_secret_key(secp_ctx, &privkey) + }; + + { + let data = OutboundRecurrenceSessionData { + offer: offer.clone(), + payer_signing_pubkey: keys.public_key(), + recurrence_start, + // Primary invoice request. Hence counter -> 0. + next_recurrence_counter: 0, + invoice_recurrence_basetime, + next_trigger_time, + }; + + let mut sessions = self.active_outbound_recurrence_sessions.lock().unwrap(); + sessions.insert(id, data); + } + + let builder = offer. + request_invoice_with_explicit_signing_pubkey(keys.public_key(), expanded_key, nonce, secp_ctx, payment_id)?; + + let builder = builder.chain_hash(self.chain_hash)?; + let builder = builder.recurrence_counter(0); + + let builder = match recurrence_start { + None => builder, + Some(start) => builder.recurrence_start(start) + }; + + (Some(keys), builder) + }, + + (None, None) => { + (None, self.flow.create_invoice_request_builder( + offer, nonce, payment_id, + )?) + }, + + // All other combinations are invalid. + _ => return Err(Bolt12SemanticError::InvalidRecurrence) + }; let builder = match quantity { None => builder, @@ -13146,7 +13819,16 @@ where Some(hrn) => builder.sourced_from_human_readable_name(hrn), }; - let invoice_request = builder.build_and_sign()?; + let invoice_request = match keys_opt { + Some(keys) => { + builder.build()? + .sign(|message: &UnsignedInvoiceRequest| + Ok(secp_ctx.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys)) + ).expect("failed verifying signature") + }, + None => builder.build_and_sign()? + }; + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); self.flow.enqueue_invoice_request( @@ -13194,6 +13876,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 +13892,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 +16043,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 +16054,94 @@ 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_inbound_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()); + + // Calculate the next_invoice_request_paywindow + let seconds_before = recurrence_fields + .recurrence_paywindow.map(|window| window.seconds_before as u64) + .unwrap_or(recurrence_fields.recurrence.period_length_secs()); + + let seconds_after = recurrence_fields + .recurrence_paywindow.map(|window| window.seconds_after as u64) + .unwrap_or(recurrence_fields.recurrence.period_length_secs()); + + // Next we prepare inbound recurrence_data to be stored in our recurrence session + let recurrence_data = InboundRecurrenceSessionData { + recurrence_fields, + invoice_request_start: invoice_request.recurrence_start(), + next_payable_counter: 0, + recurrence_basetime, + next_invoice_request_window: (recurrence_basetime.saturating_sub(seconds_before), recurrence_basetime.saturating_add(seconds_after)), + }; + // Now we store it in our active_inbound_recurrence_sessions + 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 created_at.as_secs() < data.next_invoice_request_window.0 + || created_at.as_secs() >= data.next_invoice_request_window.1 + { + 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 +16151,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 +16187,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) @@ -15446,7 +16233,6 @@ where Ok(payment_id) => payment_id, Err(()) => return None, }; - let logger = WithContext::from( &self.logger, None, None, Some(invoice.payment_hash()), ); @@ -15464,7 +16250,117 @@ where return None; } - let res = self.send_payment_for_verified_bolt12_invoice(&invoice, payment_id); + let res = { + // Extract recurrence metadata from the invoice once. + let recurrence_fields = invoice.recurrence_fields(); + let recurrence_counter = invoice.recurrence_counter(); + let invoice_recurrence_basetime = invoice.invoice_recurrence_basetime(); + + match (recurrence_fields, recurrence_counter, invoice_recurrence_basetime) { + // ------------------------------------------------------------------ + // Non-recurrent invoice + // + // No recurrence metadata is present, so this invoice does not + // participate in any recurrence protocol. We can directly send + // the payment without touching any recurrence session state. + // ------------------------------------------------------------------ + (None, None, None) => { + self.send_payment_for_verified_bolt12_invoice(&invoice, payment_id) + } + // ------------------------------------------------------------------ + // Recurrent invoice + // + // At this point the invoice is *structurally* a recurrence invoice. + // We must validate it against existing outbound recurrence session + // state before sending the payment. + // ------------------------------------------------------------------ + (Some(fields), Some(counter), Some(invoice_basetime)) => { + let mut sessions = self.active_outbound_recurrence_sessions.lock().unwrap(); + let entry = sessions + .values_mut() + .find(|session| session.payer_signing_pubkey == invoice.payer_signing_pubkey()); + + let data = match entry { + Some(data) => data, + // A recurrence invoice must always correspond to an + // existing outbound recurrence session. + None => return None, + }; + + // ------------------------------------------------------------------ + // Sanity checks + // + // 1. The recurrence start specified by the invoice must match the + // recurrence start recorded in the outbound session. + // 2. The invoice's recurrence counter must match the next expected + // counter to ensure idempotency and prevent duplicate payments + // for the same recurrence period. + // ------------------------------------------------------------------ + if data.recurrence_start != invoice.recurrence_start() { + return None + } + + if data.next_recurrence_counter != counter { + return None + } + + // ------------------------------------------------------------------ + // Recurrence basetime validation + // + // Rules: + // 1. Only the *primary* invoice (counter == 0) may initialize + // the recurrence basetime, and only if the Offer did not + // define one. + // 2. Once a basetime is established, all invoices must match it + // exactly. + // ------------------------------------------------------------------ + match (counter, data.invoice_recurrence_basetime) { + // Primary invoice, no basetime yet → initialize from invoice. + (0, None) => { + data.invoice_recurrence_basetime = Some(invoice_basetime); + let initial_offset = data.recurrence_start.unwrap_or(0); + data.next_trigger_time = Some(fields.recurrence.start_time(invoice_basetime, initial_offset)) + }, + // Basetime already exists and matches → valid. + (_, Some(data_basetime)) if data_basetime == invoice_basetime => {}, + // Any other combination violates the recurrence protocol. + _ => return None + } + + // ------------------------------------------------------------------ + // Send the payment + // + // Note: + // We intentionally hold the outbound recurrence session lock while + // sending the payment. This prevents race conditions where a nearly + // expired recurrence session could be removed between releasing the + // lock and completing payment processing. + // ------------------------------------------------------------------ + let res = + self.send_payment_for_verified_bolt12_invoice( + &invoice, + payment_id, + ); + + // ------------------------------------------------------------------ + // On successful payment, advance the recurrence state by + // incrementing the counter and computing the next trigger time. + // ------------------------------------------------------------------ + if res.is_ok() { + data.next_recurrence_counter += 1; + let next_offset = data.recurrence_start.unwrap_or(0) + data.next_recurrence_counter; + data.next_trigger_time = Some(fields.recurrence.start_time(invoice_basetime, next_offset)); + } + res + } + + // ------------------------------------------------------------------ + // Any other combination of recurrence metadata is invalid. + // ------------------------------------------------------------------ + _ => return None, + } + }; + handle_pay_invoice_res!(res, invoice, logger); }, OffersMessage::StaticInvoice(invoice) => { @@ -15747,7 +16643,8 @@ where } if let Ok((amt_msats, payer_note)) = self.pending_outbound_payments.params_for_payment_awaiting_offer(payment_id) { let offer_pay_res = - self.pay_for_offer_intern(&offer, None, Some(amt_msats), payer_note, payment_id, Some(name), + self.pay_for_offer_intern(&offer, None, Some(amt_msats), payer_note, payment_id, + None, Some(name), None, |retryable_invoice_request| { self.pending_outbound_payments .received_offer(payment_id, Some(retryable_invoice_request)) @@ -17259,6 +18156,8 @@ 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_inbound_recurrence_sessions = Some(new_hash_map()); + let mut active_outbound_recurrence_sessions = Some(new_hash_map()); read_tlv_fields!(reader, { (1, pending_outbound_payments_no_retry, option), (2, pending_intercepted_htlcs, option), @@ -17277,6 +18176,8 @@ 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_inbound_recurrence_sessions, option), + (25, active_outbound_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 +19072,13 @@ where router: args.router, flow, + active_inbound_recurrence_sessions: Mutex::new( + active_inbound_recurrence_sessions.unwrap(), + ), + active_outbound_recurrence_sessions: Mutex::new( + active_outbound_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..95e1d77865a 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() @@ -136,7 +114,7 @@ use crate::offers::merkle::{ use crate::offers::nonce::Nonce; use crate::offers::offer::{ Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream, - OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + OfferTlvStreamRef, Quantity, RecurrenceFields, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerTlvStream, PayerTlvStreamRef, PAYER_METADATA_TYPE}; @@ -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, } @@ -932,6 +964,31 @@ macro_rules! invoice_accessors { ($self: ident, $contents: expr) => { pub fn amount_msats(&$self) -> u64 { $contents.amount_msats() } + + /// Returns underlying offer's recurrence fields + pub fn recurrence_fields(&$self) -> Option { + $contents.recurrence_fields() + } + + /// Returns the recurrence counter (if present) on this invoice request. + pub fn recurrence_counter(&$self) -> Option { + $contents.recurrence_counter() + } + + /// Returns the recurrence_start (if present) for this invoice request. + pub fn recurrence_start(&$self) -> Option { + $contents.recurrence_start() + } + + /// Returns Some(()) if this invoice request signals recurrence cancellation. + pub fn recurrence_cancel(&$self) -> Option<()> { + $contents.recurrence_cancel() + } + + /// Returns the invoice_recurrence_basetime (if present). + pub fn invoice_recurrence_basetime(&$self) -> Option { + $contents.invoice_recurrence_basetime() + } } } macro_rules! invoice_accessors_signing_pubkey { @@ -1272,6 +1329,48 @@ impl InvoiceContents { self.fields().signing_pubkey } + /// Returns underlying offer's RecurrenceFields + fn recurrence_fields(&self) -> Option { + match self { + InvoiceContents::ForOffer { invoice_request, .. } => { + invoice_request.inner.offer.recurrence_fields() + }, + InvoiceContents::ForRefund { .. } => None, + } + } + + fn recurrence_counter(&self) -> Option { + match self { + InvoiceContents::ForOffer { invoice_request, .. } => { + invoice_request.recurrence_counter() + }, + InvoiceContents::ForRefund { .. } => None, + } + } + + fn recurrence_start(&self) -> Option { + match self { + InvoiceContents::ForOffer { invoice_request, .. } => invoice_request.recurrence_start(), + InvoiceContents::ForRefund { .. } => None, + } + } + + fn recurrence_cancel(&self) -> Option<()> { + match self { + InvoiceContents::ForOffer { invoice_request, .. } => { + invoice_request.recurrence_cancel() + }, + InvoiceContents::ForRefund { .. } => None, + } + } + + fn invoice_recurrence_basetime(&self) -> Option { + match self { + InvoiceContents::ForOffer { fields, .. } => fields.invoice_recurrence_basetime, + InvoiceContents::ForRefund { .. } => None, + } + } + fn fields(&self) -> &InvoiceFields { match self { InvoiceContents::ForOffer { fields, .. } => fields, @@ -1402,6 +1501,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 +1583,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 +1775,7 @@ impl TryFrom for InvoiceContents { features, node_id, message_paths, + invoice_recurrence_basetime, }, experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream, @@ -1713,6 +1815,7 @@ impl TryFrom for InvoiceContents { fallbacks, features, signing_pubkey, + invoice_recurrence_basetime, #[cfg(test)] experimental_baz, }; @@ -1720,6 +1823,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 +1850,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 +2040,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 +2150,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 +2165,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 +2182,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 +2205,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 +2262,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 +2277,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 +2294,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 +2308,6 @@ mod tests { } } - #[cfg(feature = "std")] #[test] fn builds_invoice_from_offer_with_expiration() { let expanded_key = ExpandedKey::new([42; 32]); @@ -2148,7 +2328,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 +2343,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 +2352,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 +2362,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 +2374,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 +2423,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 +2455,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 +2492,7 @@ mod tests { .unwrap(); let invoice = refund - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -2346,7 +2525,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 +2546,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 +2578,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 +2608,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 +2628,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 +2656,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 +2712,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 +2740,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 +2758,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 +2785,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 +2862,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 +2906,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 +2939,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 +2983,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 +3025,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 +3068,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 +3130,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 +3217,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 +3242,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 +3284,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 +3313,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 +3336,7 @@ mod tests { .unwrap() .build() .unwrap() - .respond_using_derived_keys_no_std( + .respond_using_derived_keys( payment_paths(), payment_hash(), now(), @@ -3204,7 +3377,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 +3410,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 +3453,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 +3492,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 +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() .experimental_baz(42) .build() @@ -3391,7 +3564,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 +3605,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 +3643,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 +3684,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 +3719,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 +3767,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 +3783,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 +3813,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..c84a623259b 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -138,7 +138,7 @@ pub struct InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b> { secp_ctx: Option<&'b Secp256k1>, } -macro_rules! invoice_request_derived_payer_signing_pubkey_builder_methods { +macro_rules! invoice_request_building_methods { ( $self: ident, $self_type: ty, $secp_context: ty ) => { @@ -158,6 +158,22 @@ macro_rules! invoice_request_derived_payer_signing_pubkey_builder_methods { } } + #[cfg_attr(c_bindings, allow(dead_code))] + pub(super) fn explicit_signing_pubkey( + offer: &'a Offer, payer_signing_pubkey: PublicKey, expanded_key: &ExpandedKey, + nonce: Nonce, secp_ctx: &'b Secp256k1<$secp_context>, payment_id: PaymentId, + ) -> Self { + let payment_id = Some(payment_id); + let derivation_material = MetadataMaterial::new(nonce, expanded_key, payment_id); + let metadata = Metadata::Derived(derivation_material); + Self { + offer, + invoice_request: Self::create_contents(offer, metadata), + payer_signing_pubkey: Some(payer_signing_pubkey), + secp_ctx: Some(secp_ctx), + } + } + /// Builds a signed [`InvoiceRequest`] after checking for valid semantics. pub fn build_and_sign($self: $self_type) -> Result { let (unsigned_invoice_request, keys, secp_ctx) = $self.build_with_checks()?; @@ -174,6 +190,16 @@ macro_rules! invoice_request_derived_payer_signing_pubkey_builder_methods { .unwrap(); Ok(invoice_request) } + + /// Builds an unsigned [`InvoiceRequest`] after checking for valid semantics. It can be signed + /// by [`UnsignedInvoiceRequest::sign`]. + pub(crate) fn build( + $self: $self_type, + ) -> Result { + let (unsigned_invoice_request, keys, _) = $self.build_with_checks()?; + debug_assert!(keys.is_none()); + Ok(unsigned_invoice_request) + } }; } @@ -186,7 +212,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, } @@ -255,6 +282,31 @@ macro_rules! invoice_request_builder_methods { ( $return_value } + /// Sets the [`InvoiceRequest::recurrence_counter`]. + /// + /// Successive calls to this method override the previous setting. + pub fn recurrence_counter($($self_mut)* $self: $self_type, counter: u32) -> $return_type { + $self.invoice_request.recurrence_counter = Some(counter); + $return_value + } + + /// Sets the [`InvoiceRequest::recurrence_start`]. + /// + /// Successive calls to this method override the previous setting. + pub fn recurrence_start($($self_mut)* $self: $self_type, start: u32) -> $return_type { + $self.invoice_request.recurrence_start = Some(start); + $return_value + } + + /// Marks this invoice request as a recurrence cancellation. + /// + /// MUST NOT be used on the first invoice request (`recurrence_counter = 0`). + /// Successive calls override the previous setting. + pub fn recurrence_cancel($($self_mut)* $self: $self_type) -> $return_type { + $self.invoice_request.recurrence_cancel = Some(()); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -401,7 +453,7 @@ macro_rules! invoice_request_builder_test_methods { ( } } impl<'a, 'b, T: secp256k1::Signing> InvoiceRequestBuilder<'a, 'b, T> { - invoice_request_derived_payer_signing_pubkey_builder_methods!(self, Self, T); + invoice_request_building_methods!(self, Self, T); invoice_request_builder_methods!(self, Self, Self, self, T, mut); #[cfg(test)] @@ -410,13 +462,13 @@ impl<'a, 'b, T: secp256k1::Signing> InvoiceRequestBuilder<'a, 'b, T> { #[cfg(all(c_bindings, not(test)))] impl<'a, 'b> InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b> { - invoice_request_derived_payer_signing_pubkey_builder_methods!(self, &mut Self, secp256k1::All); + invoice_request_building_methods!(self, &mut Self, secp256k1::All); invoice_request_builder_methods!(self, &mut Self, (), (), secp256k1::All); } #[cfg(all(c_bindings, test))] impl<'a, 'b> InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b> { - invoice_request_derived_payer_signing_pubkey_builder_methods!(self, &mut Self, secp256k1::All); + invoice_request_building_methods!(self, &mut Self, secp256k1::All); invoice_request_builder_methods!(self, &mut Self, &mut Self, self, secp256k1::All); invoice_request_builder_test_methods!(self, &mut Self, &mut Self, self); } @@ -686,6 +738,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 +818,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 +863,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 +888,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 +906,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 +1084,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 +1120,7 @@ macro_rules! fields_accessor { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, + recurrence_counter, .. }, } = &$inner; @@ -1063,6 +1134,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 +1239,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 +1306,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 +1369,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 +1524,9 @@ impl TryFrom for InvoiceRequestContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { @@ -1470,6 +1563,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 +1669,9 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, #[cfg(test)] experimental_bar, }, @@ -1505,6 +1697,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 +1727,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 +1740,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 +1748,7 @@ impl Readable for InvoiceRequestFields { quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + recurrence_counter, }) } } @@ -1647,6 +1855,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 +1870,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 +1944,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 +2528,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 +3328,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..263f87d93cb 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,387 @@ 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) -> u64 { + 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) + .expect("recurrence period length should not overflow.") + } + + /// For a give period number, gives it start time in UNIX + pub fn start_time(&self, basetime: u64, period_number: u32) -> u64 { + basetime + (period_number as u64).saturating_mul(self.period_length_secs()) + } +} + +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 InboundRecurrenceSessionData { + /// Recurrence period + pub recurrence_fields: RecurrenceFields, + + /// 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, + + /// The valid time window for receiving the next recurrence-enabled + /// `invoice_request`. + /// + /// This window is defined as: + /// - the start of the next recurrence period, and + /// - the end of the corresponding paywindow (or the full period length + /// if no explicit paywindow is defined). + /// + /// If an `invoice_request` arrives **before** the start of this window, + /// the payer is early and the request is ignored. + /// If no valid `invoice_request` arrives **by the end** of this window, + /// the recurrence period is considered missed and the session is void. + pub next_invoice_request_window: (u64, u64), +} + +impl_writeable_tlv_based!(InboundRecurrenceSessionData, { + (0, recurrence_fields, required), + (2, invoice_request_start, option), + (4, next_payable_counter, required), + (6, recurrence_basetime, required), + (8, next_invoice_request_window, required), +}); + +/// Contains all the information and state required to maintain an outbound +/// recurrence sesssion flow. +pub struct OutboundRecurrenceSessionData { + /// The offer corresponding to which this recurrence is. + /// + /// We need to store the entire offer, because we need it for the creation + /// of successive invoice request (for reference, invoice request contains + /// it's own offer.) + /// + /// Since we are storing the entire offer, here I opt for not storing the + /// Offer' recurrence fields separately, since we can access them directly + /// from offer's callers. + pub offer: Offer, + /// The payer signing public key used to sign the initial (primary) + /// invoice request, and consequently all successive invoice requests + /// in this recurrence session. + /// + /// This is stored to: + /// - Keep the signing key readily available for future requests, and + /// - Ensure that any derived [`KeyPair`] continues to resolve to the same + /// payer signing public key. + pub payer_signing_pubkey: PublicKey, + /// The recurrence start we set with the primary invoice request we sent. + pub recurrence_start: Option, + /// The next payable counter of period for the payer. + pub next_recurrence_counter: u32, + /// The basetime of the first invoice of the recurrence. + /// If the offer doesn't define an offer_basetime, this will be set equal + /// to first invoice's invoice_recurrence_basetime, ones we receive it. + pub invoice_recurrence_basetime: Option, + /// Tracker that keeps track of when's the recurrence should be triggered (in UNIX). + pub next_trigger_time: Option, +} + +impl_writeable_tlv_based!(OutboundRecurrenceSessionData, { + (0, offer, required), + (2, payer_signing_pubkey, required), + (4, recurrence_start, option), + (6, next_recurrence_counter, required), + (8, invoice_recurrence_basetime, required), + (10, next_trigger_time, 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, +} + +impl_writeable_tlv_based!(RecurrenceFields, { + (0, recurrence, required), + (2, recurrence_base, option), + (4, recurrence_paywindow, option), + (6, 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 +1097,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 { @@ -765,7 +1159,7 @@ impl Offer { } } -macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: expr, $builder: ty, $hrn: expr) => { +macro_rules! request_invoice_any_signing_pubkey { ($self: ident, $offer: expr, $builder: ty, $hrn: expr) => { /// Creates an [`InvoiceRequestBuilder`] for the offer, which /// - derives the [`InvoiceRequest::payer_signing_pubkey`] such that a different key can be used /// for each request in order to protect the sender's privacy, @@ -811,16 +1205,66 @@ macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: exp } Ok(builder) } + + /// Creates an [`InvoiceRequestBuilder`] for the offer using an explicitly provided + /// payer signing pubkey. + /// + /// This allows callers to bypass derived signing keys when they want full control + /// over the signing identity used for the invoice request. + /// + /// Errors if the offer contains unknown required features. + pub fn request_invoice_with_explicit_signing_pubkey< + 'a, 'b, + #[cfg(not(c_bindings))] + T: secp256k1::Signing + >( + &'a $self, + payer_signing_pubkey: PublicKey, + expanded_key: &ExpandedKey, + nonce: Nonce, + #[cfg(not(c_bindings))] + secp_ctx: &'b Secp256k1, + #[cfg(c_bindings)] + secp_ctx: &'b Secp256k1, + payment_id: PaymentId, + ) -> Result<$builder, Bolt12SemanticError> { + if $offer.offer_features().requires_unknown_bits() { + return Err(Bolt12SemanticError::UnknownRequiredFeatures); + } + + let mut builder = + <$builder>::explicit_signing_pubkey( + &$offer, + payer_signing_pubkey, + expanded_key, + nonce, + secp_ctx, + payment_id, + ); + + if let Some(hrn) = $hrn { + #[cfg(c_bindings)] + { + builder.sourced_from_human_readable_name(hrn); + } + #[cfg(not(c_bindings))] + { + builder = builder.sourced_from_human_readable_name(hrn); + } + } + + Ok(builder) + } } } #[cfg(not(c_bindings))] impl Offer { - request_invoice_derived_signing_pubkey!(self, self, InvoiceRequestBuilder<'a, 'b, T>, None); + request_invoice_any_signing_pubkey!(self, self, InvoiceRequestBuilder<'a, 'b, T>, None); } #[cfg(not(c_bindings))] impl OfferFromHrn { - request_invoice_derived_signing_pubkey!( + request_invoice_any_signing_pubkey!( self, self.offer, InvoiceRequestBuilder<'a, 'b, T>, @@ -830,7 +1274,7 @@ impl OfferFromHrn { #[cfg(c_bindings)] impl Offer { - request_invoice_derived_signing_pubkey!( + request_invoice_any_signing_pubkey!( self, self, InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b>, @@ -840,7 +1284,7 @@ impl Offer { #[cfg(c_bindings)] impl OfferFromHrn { - request_invoice_derived_signing_pubkey!( + request_invoice_any_signing_pubkey!( self, self.offer, InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b>, @@ -993,6 +1437,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 +1508,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 +1537,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 +1696,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 +1797,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 +1848,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 +1894,7 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, + recurrence_fields, #[cfg(test)] experimental_foo, }) @@ -1429,6 +1972,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 +1989,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 },