diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..2140fc1822d 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -98,6 +98,7 @@ fn build_response( .payer_note() .map(|s| UntrustedString(s.to_string())), human_readable_name: None, + invoice_request_recurrence: None, } }; diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..fc48daeee68 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -727,6 +727,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -885,6 +886,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1006,6 +1008,7 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); @@ -1274,6 +1277,7 @@ fn creates_and_pays_for_offer_with_retry() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1339,6 +1343,7 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); @@ -1436,6 +1441,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -2647,6 +2653,7 @@ fn creates_and_pays_for_phantom_offer() { quantity: None, payer_note_truncated: None, human_readable_name: None, + invoice_request_recurrence: None, }, }); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..bbeeb99e71e 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -243,9 +243,11 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn for_offer( invoice_request: &'a InvoiceRequest, payment_paths: Vec, - created_at: Duration, payment_hash: PaymentHash, signing_pubkey: PublicKey, + created_at: Duration, recurrence_basetime: Option, payment_hash: PaymentHash, + signing_pubkey: PublicKey, ) -> Result { let amount_msats = Self::amount_msats(invoice_request)?; + let invoice_recurrence = Self::recurrence_fields(invoice_request, recurrence_basetime)?; let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), fields: Self::fields( @@ -254,6 +256,7 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + invoice_recurrence, ), }; @@ -274,6 +277,7 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + None, ), }; @@ -315,9 +319,11 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn for_offer_using_keys( invoice_request: &'a InvoiceRequest, payment_paths: Vec, - created_at: Duration, payment_hash: PaymentHash, keys: Keypair, + created_at: Duration, recurrence_basetime: Option, payment_hash: PaymentHash, + keys: Keypair, ) -> Result { let amount_msats = Self::amount_msats(invoice_request)?; + let invoice_recurrence = Self::recurrence_fields(invoice_request, recurrence_basetime)?; let signing_pubkey = keys.public_key(); let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), @@ -327,6 +333,7 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + invoice_recurrence, ), }; @@ -348,6 +355,7 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { payment_hash, amount_msats, signing_pubkey, + None, ), }; @@ -408,10 +416,18 @@ macro_rules! invoice_builder_methods { } } + pub(crate) fn recurrence_fields( + _invoice_request: &InvoiceRequest, _recurrence_basetime: Option, + ) -> Result, Bolt12SemanticError> { + //TODO: Future commits will introduce the recurrence token creation logic + return Ok(None) + } + #[cfg_attr(c_bindings, allow(dead_code))] fn fields( payment_paths: Vec, created_at: Duration, payment_hash: PaymentHash, amount_msats: u64, signing_pubkey: PublicKey, + invoice_recurrence: Option, ) -> InvoiceFields { InvoiceFields { payment_paths, @@ -424,6 +440,7 @@ macro_rules! invoice_builder_methods { signing_pubkey, #[cfg(test)] experimental_baz: None, + invoice_recurrence, } } @@ -775,6 +792,18 @@ struct InvoiceFields { signing_pubkey: PublicKey, #[cfg(test)] experimental_baz: Option, + invoice_recurrence: Option, +} + +#[derive(Clone, Debug, PartialEq)] +/// Recurrence fields included in an invoice for a recurring offer. +/// +/// `recurrence_basetime` anchors period 0 for recurring invoices when the offer did not include +/// an explicit recurrence base. `recurrence_token` is the opaque token issued by the payee for +/// the payer to echo in the next recurring [`InvoiceRequest`]. +pub struct InvoiceRecurrence { + recurrence_basetime: u64, + recurrence_token: Vec, } macro_rules! invoice_accessors { ($self: ident, $contents: expr) => { @@ -1415,6 +1444,14 @@ impl InvoiceFields { } }; + let (invoice_recurrence_basetime, invoice_recurrence_token) = match &self.invoice_recurrence + { + None => (None, None), + Some(recurrence) => { + (Some(recurrence.recurrence_basetime), Some(recurrence.recurrence_token.as_ref())) + }, + }; + ( InvoiceTlvStreamRef { paths: Some(Iterable( @@ -1429,6 +1466,8 @@ impl InvoiceFields { features, node_id: Some(&self.signing_pubkey), held_htlc_available_paths: None, + invoice_recurrence_basetime, + invoice_recurrence_token, }, ExperimentalInvoiceTlvStreamRef { #[cfg(test)] @@ -1510,6 +1549,8 @@ tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, { (172, fallbacks: (Vec, WithoutLength)), (174, features: (Bolt12InvoiceFeatures, WithoutLength)), (176, node_id: PublicKey), + (177, invoice_recurrence_basetime: (u64, HighZeroBytesDroppedBigSize)), + (179, invoice_recurrence_token: (Vec, WithoutLength)), // Only present in `StaticInvoice`s. (236, held_htlc_available_paths: (Vec, WithoutLength)), }); @@ -1701,6 +1742,8 @@ impl TryFrom for InvoiceContents { features, node_id, held_htlc_available_paths, + invoice_recurrence_basetime, + invoice_recurrence_token, }, experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream, @@ -1731,6 +1774,14 @@ impl TryFrom for InvoiceContents { let signing_pubkey = node_id.ok_or(Bolt12SemanticError::MissingSigningPubkey)?; + let invoice_recurrence = match (invoice_recurrence_basetime, invoice_recurrence_token) { + (None, None) => None, + (Some(basetime), Some(token)) => { + Some(InvoiceRecurrence { recurrence_basetime: basetime, recurrence_token: token }) + }, + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + }; + let fields = InvoiceFields { payment_paths, created_at, @@ -1742,6 +1793,7 @@ impl TryFrom for InvoiceContents { signing_pubkey, #[cfg(test)] experimental_baz, + invoice_recurrence, }; check_invoice_signing_pubkey(&fields.signing_pubkey, &offer_tlv_stream)?; @@ -1759,6 +1811,10 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::InvalidAmount); } + if fields.invoice_recurrence.is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + Ok(InvoiceContents::ForRefund { refund, fields }) } else { let invoice_request = InvoiceRequestContents::try_from(( @@ -1825,8 +1881,9 @@ pub(super) fn check_invoice_signing_pubkey( mod tests { use super::{ Bolt12Invoice, ExperimentalInvoiceTlvStreamRef, FallbackAddress, FullInvoiceTlvStreamRef, - InvoiceTlvStreamRef, UnsignedBolt12Invoice, DEFAULT_RELATIVE_EXPIRY, - EXPERIMENTAL_INVOICE_TYPES, INVOICE_TYPES, SIGNATURE_TAG, + InvoiceContents, InvoiceFields, InvoiceRecurrence, InvoiceTlvStreamRef, + UnsignedBolt12Invoice, DEFAULT_RELATIVE_EXPIRY, EXPERIMENTAL_INVOICE_TYPES, INVOICE_TYPES, + SIGNATURE_TAG, }; use bitcoin::address::Address; @@ -2014,6 +2071,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, @@ -2024,6 +2086,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( @@ -2038,6 +2104,8 @@ mod tests { features: None, node_id: Some(&recipient_pubkey()), held_htlc_available_paths: None, + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -2117,6 +2185,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, @@ -2127,6 +2200,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, InvoiceTlvStreamRef { paths: Some(Iterable( @@ -2141,6 +2218,8 @@ mod tests { features: None, node_id: Some(&recipient_pubkey()), held_htlc_available_paths: None, + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -3375,6 +3454,163 @@ mod tests { } } + #[test] + fn parses_invoice_with_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_basetime = 123_456; + let recurrence_token = vec![1, 2, 3]; + + let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let contents = InvoiceContents::ForOffer { + invoice_request: invoice_request.contents.clone(), + fields: InvoiceFields { + payment_paths: payment_paths(), + created_at: now(), + relative_expiry: None, + payment_hash: payment_hash(), + amount_msats: 1000, + fallbacks: None, + features: Bolt12InvoiceFeatures::empty(), + signing_pubkey: recipient_pubkey(), + #[cfg(test)] + experimental_baz: None, + invoice_recurrence: Some(InvoiceRecurrence { + recurrence_basetime, + recurrence_token: recurrence_token.clone(), + }), + }, + }; + + let invoice = UnsignedBolt12Invoice::new(invoice_request.bytes(), contents) + .sign(recipient_sign) + .unwrap(); + + let mut buffer = Vec::new(); + invoice.write(&mut buffer).unwrap(); + + match Bolt12Invoice::try_from(buffer) { + Ok(invoice) => { + match &invoice.contents { + InvoiceContents::ForOffer { fields, .. } => { + assert_eq!( + fields.invoice_recurrence, + Some(InvoiceRecurrence { + recurrence_basetime, + recurrence_token: recurrence_token.clone(), + }) + ); + }, + InvoiceContents::ForRefund { .. } => panic!("expected offer invoice"), + } + + let tlv_stream = invoice.as_tlv_stream(); + assert_eq!(tlv_stream.3.invoice_recurrence_basetime, Some(recurrence_basetime)); + assert_eq!(tlv_stream.3.invoice_recurrence_token, Some(&recurrence_token)); + }, + Err(e) => panic!("error parsing invoice: {:?}", e), + } + } + + #[test] + fn fails_parsing_invoice_with_invalid_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_token = vec![1, 2, 3]; + + let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let contents = InvoiceContents::ForOffer { + invoice_request: invoice_request.contents.clone(), + fields: InvoiceFields { + payment_paths: payment_paths(), + created_at: now(), + relative_expiry: None, + payment_hash: payment_hash(), + amount_msats: 1000, + fallbacks: None, + features: Bolt12InvoiceFeatures::empty(), + signing_pubkey: recipient_pubkey(), + #[cfg(test)] + experimental_baz: None, + invoice_recurrence: Some(InvoiceRecurrence { + recurrence_basetime: 123_456, + recurrence_token: recurrence_token.clone(), + }), + }, + }; + + let invoice = UnsignedBolt12Invoice::new(invoice_request.bytes(), contents) + .sign(recipient_sign) + .unwrap(); + + let mut missing_token = invoice.as_tlv_stream(); + missing_token.3.invoice_recurrence_token = None; + + match Bolt12Invoice::try_from(missing_token.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + + let mut missing_basetime = invoice.as_tlv_stream(); + missing_basetime.3.invoice_recurrence_basetime = None; + + match Bolt12Invoice::try_from(missing_basetime.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + } + + #[test] + fn fails_parsing_refund_invoice_with_recurrence() { + let refund = + RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap().build().unwrap(); + + let invoice = refund + .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let recurrence_token = vec![1, 2, 3]; + let mut tlv_stream = invoice.as_tlv_stream(); + tlv_stream.3.invoice_recurrence_basetime = Some(123_456); + tlv_stream.3.invoice_recurrence_token = Some(&recurrence_token); + + match Bolt12Invoice::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ), + } + } + #[test] fn parses_invoice_with_experimental_tlv_records() { let expanded_key = ExpandedKey::new([42; 32]); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..b1b599b193f 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -78,7 +78,7 @@ use crate::offers::merkle::{ use crate::offers::nonce::Nonce; use crate::offers::offer::{ Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents, - OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + OfferId, OfferTlvStream, OfferTlvStreamRef, Recurrence, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef}; @@ -91,11 +91,14 @@ use crate::util::ser::{ CursorReadable, HighZeroBytesDroppedBigSize, LengthLimitedRead, LengthReadable, Readable, WithoutLength, Writeable, Writer, }; + use bitcoin::constants::ChainHash; use bitcoin::network::Network; use bitcoin::secp256k1::schnorr::Signature; use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1}; +use core::time::Duration; + #[cfg(not(c_bindings))] use crate::offers::invoice::InvoiceBuilder; #[cfg(c_bindings)] @@ -183,10 +186,11 @@ macro_rules! invoice_request_builder_methods { ( #[cfg_attr(c_bindings, allow(dead_code))] fn create_contents(offer: &Offer, metadata: Metadata) -> InvoiceRequestContentsWithoutPayerSigningPubkey { let offer = offer.contents.clone(); + InvoiceRequestContentsWithoutPayerSigningPubkey { payer: PayerContents(metadata), offer, chain: None, amount_msats: None, features: InvoiceRequestFeatures::empty(), quantity: None, payer_note: None, - offer_from_hrn: None, + offer_from_hrn: None, invoice_request_recurrence: None, #[cfg(test)] experimental_bar: None, } @@ -255,6 +259,18 @@ macro_rules! invoice_request_builder_methods { ( $return_value } + /// Sets all recurrence-related fields for this invoice request in one call. + /// + /// `invoice_request_recurrence` must match the offer's recurrence configuration. Use + /// [`InvoiceRequestRecurrence::WithOfferBasetime`] for offers with an explicit recurrence + /// basetime and [`InvoiceRequestRecurrence::WithoutOfferBasetime`] otherwise. + /// + /// Successive calls override the previous setting. + pub fn set_invoice_request_recurrence($($self_mut)* $self: $self_type, invoice_request_recurrence: InvoiceRequestRecurrence) -> $return_type { + $self.invoice_request.invoice_request_recurrence = Some(invoice_request_recurrence); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -278,6 +294,25 @@ macro_rules! invoice_request_builder_methods { ( return Err(Bolt12SemanticError::MissingAmount); } + // Ensure the invoice request recurrence form matches the offer's recurrence configuration. + match ($self.offer.offer_recurrence(), &$self.invoice_request.invoice_request_recurrence) { + (None, None) => (), + ( + Some(Recurrence::Compulsory { base: Some(_), .. }), + Some(InvoiceRequestRecurrence::WithOfferBasetime { .. }) + ) => (), + ( + Some(Recurrence::Compulsory { base: None, .. }), + Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }) + ) => (), + ( + Some(Recurrence::Optional { .. }), + None | Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }) + ) => (), + + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + } + $self.invoice_request.offer.check_quantity($self.invoice_request.quantity)?; $self.invoice_request.offer.check_amount_msats_for_quantity( $self.invoice_request.amount_msats, $self.invoice_request.quantity @@ -609,6 +644,8 @@ pub struct VerifiedInvoiceRequest { /// The verified request. pub(crate) inner: InvoiceRequest, + pub(crate) resolved_basetime: Option, + /// Keys for signing a [`Bolt12Invoice`] for the request. /// #[cfg_attr( @@ -675,6 +712,94 @@ pub(super) struct InvoiceRequestContents { payer_signing_pubkey: PublicKey, } +/// Recurrence-specific fields carried by an [`InvoiceRequest`]. +/// +/// These fields identify which recurring request this is and, when present, echo opaque +/// payee-authored state from previous invoice into next recurring request. +/// +/// `counter` always identifies payer's own recurring request number. When offer has explicit +/// basetime, schedule period index is: +/// period_index = start + counter +/// +/// `token`, when present, is exact byte string chosen by payee in previous invoice and echoed +/// back unchanged by payer. Payer must not interpret or modify it. +/// +/// `cancel` marks request as recurrence cancellation and is invalid on first recurring request +/// (`counter = 0`). +/// +/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InvoiceRequestRecurrence { + /// Recurrence fields for offers with an explicit recurrence basetime. + WithOfferBasetime(WithOfferBasetimeRecurrence), + /// Recurrence fields for offers without an explicit recurrence basetime. + WithoutOfferBasetime(WithoutOfferBasetimeRecurrence), +} + +/// Recurrence fields for invoice requests whose offer recurrence is anchored to an explicit +/// basetime. +/// +/// `counter` identifies which recurring request this is in the payer's sequence. `start` +/// identifies the schedule period where the payer began recurring payments. `token`, when +/// present, is the opaque recurrence token echoed from the previous invoice. `cancel`, when +/// present, marks the request as a recurrence cancellation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WithOfferBasetimeRecurrence { + counter: u32, + start: u32, + token: Option>, + cancel: Option<()>, +} + +/// Recurrence fields for invoice requests whose offer recurrence is anchored to the first +/// successful payment instead of an explicit basetime. +/// +/// `counter` identifies which recurring request this is in the payer's sequence. `token`, when +/// present, is the opaque recurrence token echoed from the previous invoice. `cancel`, when +/// present, marks the request as a recurrence cancellation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WithoutOfferBasetimeRecurrence { + counter: u32, + token: Option>, + cancel: Option<()>, +} + +impl InvoiceRequestRecurrence { + pub(crate) fn new( + recurrence_counter: Option, recurrence_start: Option, + recurrence_token: Option>, recurrence_cancel: Option<()>, + ) -> Result, ()> { + match (recurrence_counter, recurrence_start, recurrence_token, recurrence_cancel) { + (None, None, None, None) => Ok(None), + // Primary invoice requests (counter 0) cannot include token or cancellation state. + (Some(0), _, Some(_), _) | (Some(0), _, _, Some(_)) => Err(()), + (Some(counter), Some(start), token, cancel) => { + let inner = WithOfferBasetimeRecurrence { counter, start, token, cancel }; + Ok(Some(Self::WithOfferBasetime(inner))) + }, + (Some(counter), None, token, cancel) => { + let inner = WithoutOfferBasetimeRecurrence { counter, token, cancel }; + Ok(Some(Self::WithoutOfferBasetime(inner))) + }, + _ => Err(()), + } + } + + pub(crate) fn fields(&self) -> (Option, Option, Option<&Vec>, Option<&()>) { + match self { + Self::WithOfferBasetime(inner) => ( + Some(inner.counter), + Some(inner.start), + inner.token.as_ref(), + inner.cancel.as_ref(), + ), + Self::WithoutOfferBasetime(inner) => { + (Some(inner.counter), None, inner.token.as_ref(), inner.cancel.as_ref()) + }, + } + } +} + #[derive(Clone, Debug)] #[cfg_attr(test, derive(PartialEq))] pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { @@ -686,6 +811,22 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, + /// Recurrence fields for this invoice request. + /// + /// `counter` identifies which recurring invoice request this is in the payer's own sequence. + /// It does not necessarily equal the schedule period index. + /// + /// When `start` is present, the schedule period index is: + /// period_index = recurrence_start + recurrence_counter + /// + /// `start` is only meaningful when the offer defines an explicit recurrence basetime. For + /// example, a monthly schedule anchored on January 1st would use `start = 3` to begin on April + /// 1st. + /// + /// `cancel` marks this as a recurrence cancellation request. It is invalid on the first + /// recurring request (`counter = 0`). `token` is a speculative opaque echo field from the + /// previous invoice and is likewise invalid on the first recurring request. + invoice_request_recurrence: Option, #[cfg(test)] experimental_bar: Option, } @@ -736,6 +877,11 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { $contents.payer_signing_pubkey() } + /// Recurrence fields copied from this invoice request, if it belongs to a recurring offer. + pub fn invoice_request_recurrence(&$self) -> &Option { + $contents.invoice_request_recurrence() + } + /// A payer-provided note which will be seen by the recipient and reflected back in the invoice /// response. pub fn payer_note(&$self) -> Option> { @@ -754,8 +900,42 @@ impl UnsignedInvoiceRequest { invoice_request_accessors!(self, self.contents); } +macro_rules! invoice_request_recurrence_methods { ( + $self: ident, $contents: expr +) => { + pub(crate) fn recurrence_basetime( + &$self, created_at: Duration, resolved_basetime: Option, + ) -> Result, Bolt12SemanticError> { + let offer_recurrence = match $self.offer_recurrence() { + Some(offer_recurrence) => offer_recurrence, + None => return Ok(None), + }; + + let offer_base = match offer_recurrence { + Recurrence::Optional { .. } => None, + Recurrence::Compulsory { base, .. } => base, + }; + + let recurrence_counter = $contents + .invoice_request_recurrence() + .as_ref() + .and_then(|recurrence| recurrence.fields().0); + + match (offer_base, recurrence_counter) { + (Some(base), _) => Ok(Some(base.basetime)), + (None, Some(0)) => Ok(Some(created_at.as_secs())), + (None, Some(_)) => { + resolved_basetime + .map(Some) + .ok_or(Bolt12SemanticError::InvalidMetadata) + }, + (None, None) => Ok(Some(created_at.as_secs())), + } + } +}; } + macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( - $self: ident, $contents: expr, $builder: ty + $self: ident, $contents: expr, $basetime: expr, $builder: ty ) => { /// Creates an [`InvoiceBuilder`] for the request with the given required fields and using the /// [`Duration`] since [`std::time::SystemTime::UNIX_EPOCH`] as the creation time. @@ -813,7 +993,9 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( None => return Err(Bolt12SemanticError::MissingIssuerSigningPubkey), }; - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + let recurrence_basetime = $self.recurrence_basetime(created_at, $basetime)?; + + <$builder>::for_offer(&$contents, payment_paths, created_at, recurrence_basetime, payment_hash, signing_pubkey) } #[cfg(test)] @@ -828,14 +1010,34 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( return Err(Bolt12SemanticError::UnknownRequiredFeatures); } - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + let recurrence_basetime = $self.recurrence_basetime(created_at, $basetime)?; + + <$builder>::for_offer(&$contents, payment_paths, created_at, recurrence_basetime, payment_hash, signing_pubkey) } } } macro_rules! invoice_request_verify_method { ($self: ident, $self_type: ty) => { + /// Verifies the speculative recurrence token echoed by the payer and resolves the recurrence + /// basetime to reuse for subsequent invoices. + /// + /// Returns `Ok(None)` when the request carries no recurrence token. + // Token validation and basetime recovery for token-carrying requests are deferred to follow-up work. + pub(crate) fn verify_recurrence_token( + $self: &$self_type, _key: &ExpandedKey, + ) -> Result, ()> { + let recurrence_token = + $self.invoice_request_recurrence().as_ref().and_then(|rec| rec.fields().2); + + if recurrence_token.is_some() { + // TODO: "Token verification and basetime resolve logic to be implemented in follow-up commits." + Err(()) + } else { + Ok(None) + } + } /// Verifies that the request was for an offer created using the given key by checking the - /// metadata from the offer. + /// metadata from the offer. /// /// Returns the verified request which contains the derived keys needed to sign a /// [`Bolt12Invoice`] for the request if they could be extracted from the metadata. @@ -861,15 +1063,19 @@ macro_rules! invoice_request_verify_method { { $self.clone() } }; + let resolved_basetime = inner.verify_recurrence_token(key)?; + let verified = match keys { None => InvoiceRequestVerifiedFromOffer::ExplicitKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: ExplicitSigningPubkey {}, }), Some(keys) => InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: DerivedSigningPubkey(keys), }), }; @@ -878,7 +1084,7 @@ macro_rules! invoice_request_verify_method { } /// Verifies that the request was for an offer created using the given key by checking a nonce - /// included with the [`BlindedMessagePath`] for which the request was sent through. + /// included with the [`BlindedMessagePath`] for which the request was sent through. /// /// Returns the verified request which contains the derived keys needed to sign a /// [`Bolt12Invoice`] for the request if they could be extracted from the metadata. @@ -906,15 +1112,19 @@ macro_rules! invoice_request_verify_method { { $self.clone() } }; + let resolved_basetime = inner.verify_recurrence_token(key)?; + let verified = match keys { None => InvoiceRequestVerifiedFromOffer::ExplicitKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: ExplicitSigningPubkey {}, }), Some(keys) => InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { offer_id, inner, + resolved_basetime, keys: DerivedSigningPubkey(keys), }), }; @@ -928,9 +1138,12 @@ macro_rules! invoice_request_verify_method { impl InvoiceRequest { offer_accessors!(self, self.contents.inner.offer); invoice_request_accessors!(self, self.contents); + invoice_request_recurrence_methods!(self, self.contents); + invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self, + None, InvoiceBuilder<'_, ExplicitSigningPubkey> ); invoice_request_verify_method!(self, Self); @@ -945,9 +1158,12 @@ impl InvoiceRequest { impl InvoiceRequest { offer_accessors!(self, self.contents.inner.offer); invoice_request_accessors!(self, self.contents); + invoice_request_recurrence_methods!(self, self.contents); + invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self, + None, InvoiceWithExplicitSigningPubkeyBuilder ); invoice_request_verify_method!(self, &Self); @@ -987,7 +1203,7 @@ impl InvoiceRequest { } macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( - $self: ident, $contents: expr, $builder: ty + $self: ident, $contents: expr, $basetime: expr, $builder: ty ) => { /// Creates an [`InvoiceBuilder`] for the request using the given required fields and that uses /// derived signing keys from the originating [`Offer`] to sign the [`Bolt12Invoice`]. Must use @@ -1029,8 +1245,10 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( None => return Err(Bolt12SemanticError::MissingIssuerSigningPubkey), } + let recurrence_basetime = $self.recurrence_basetime(created_at, $basetime)?; + <$builder>::for_offer_using_keys( - &$self.inner, payment_paths, created_at, payment_hash, keys + &$self.inner, payment_paths, created_at, recurrence_basetime, payment_hash, keys ) } } } @@ -1050,6 +1268,7 @@ macro_rules! fields_accessor { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, + invoice_request_recurrence, .. }, } = &$inner; @@ -1063,6 +1282,7 @@ macro_rules! fields_accessor { // down to the nearest valid UTF-8 code point boundary. .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), human_readable_name: $self.offer_from_hrn().clone(), + invoice_request_recurrence: invoice_request_recurrence.clone(), } } }; @@ -1073,16 +1293,20 @@ impl VerifiedInvoiceRequest { invoice_request_accessors!(self, self.inner.contents); fields_accessor!(self, self.inner.contents); + invoice_request_recurrence_methods!(self, self.inner.contents); + #[cfg(not(c_bindings))] invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceBuilder<'_, DerivedSigningPubkey> ); #[cfg(c_bindings)] invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceWithDerivedSigningPubkeyBuilder ); } @@ -1092,16 +1316,20 @@ impl VerifiedInvoiceRequest { invoice_request_accessors!(self, self.inner.contents); fields_accessor!(self, self.inner.contents); + invoice_request_recurrence_methods!(self, self.inner.contents); + #[cfg(not(c_bindings))] invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceBuilder<'_, ExplicitSigningPubkey> ); #[cfg(c_bindings)] invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, + self.resolved_basetime, InvoiceWithExplicitSigningPubkeyBuilder ); } @@ -1171,6 +1399,10 @@ impl InvoiceRequestContents { self.payer_signing_pubkey } + pub(super) fn invoice_request_recurrence(&self) -> &Option { + &self.inner.invoice_request_recurrence + } + pub(super) fn payer_note(&self) -> Option> { self.inner.payer_note.as_ref().map(|payer_note| PrintableString(payer_note.as_str())) } @@ -1213,6 +1445,12 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { } }; + let (recurrence_counter, recurrence_start, recurrence_token, recurrence_cancel) = + match self.invoice_request_recurrence.as_ref() { + None => (None, None, None, None), + Some(rec) => rec.fields(), + }; + let invoice_request = InvoiceRequestTlvStreamRef { chain: self.chain.as_ref(), amount: self.amount_msats, @@ -1222,6 +1460,10 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { payer_note: self.payer_note.as_ref(), offer_from_hrn: self.offer_from_hrn.as_ref(), paths: None, + recurrence_counter, + recurrence_start, + recurrence_cancel, + recurrence_token, }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { @@ -1282,6 +1524,12 @@ tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef<'a>, INVOICE_REQ // Only used for Refund since the onion message of an InvoiceRequest has a reply path. (90, paths: (Vec, WithoutLength)), (91, offer_from_hrn: HumanReadableName), + (92, recurrence_counter: (u32, HighZeroBytesDroppedBigSize)), + (93, recurrence_start: (u32, HighZeroBytesDroppedBigSize)), + (94, recurrence_cancel: ()), + // Speculative recurrence-token TLV pending upstream BOLT12 assignment. Type 95 is provisional + // and may change when the proposal is merged into the spec. + (95, recurrence_token: (Vec, WithoutLength)), }); /// Valid type range for experimental invoice_request TLV records. @@ -1434,6 +1682,10 @@ impl TryFrom for InvoiceRequestContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, + recurrence_token, }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { @@ -1470,21 +1722,48 @@ impl TryFrom for InvoiceRequestContents { return Err(Bolt12SemanticError::UnexpectedPaths); } - Ok(InvoiceRequestContents { - inner: InvoiceRequestContentsWithoutPayerSigningPubkey { - payer, - offer, - chain, - amount_msats: amount, - features, - quantity, - payer_note, - offer_from_hrn, - #[cfg(test)] - experimental_bar, - }, - payer_signing_pubkey, - }) + let invoice_request_recurrence = InvoiceRequestRecurrence::new( + recurrence_counter, + recurrence_start, + recurrence_token, + recurrence_cancel, + ) + .map_err(|_| Bolt12SemanticError::InvalidRecurrence)?; + + // Recurrence sanity check + match (offer.offer_recurrence(), &invoice_request_recurrence) { + (None, None) => (), + ( + Some(Recurrence::Compulsory { base: Some(_), .. }), + Some(InvoiceRequestRecurrence::WithOfferBasetime { .. }), + ) => (), + ( + Some(Recurrence::Compulsory { base: None, .. }), + Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }), + ) => (), + ( + Some(Recurrence::Optional { .. }), + None | Some(InvoiceRequestRecurrence::WithoutOfferBasetime { .. }), + ) => (), + + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + } + + let inner = InvoiceRequestContentsWithoutPayerSigningPubkey { + payer, + offer, + chain, + amount_msats: amount, + features, + quantity, + payer_note, + offer_from_hrn, + invoice_request_recurrence, + #[cfg(test)] + experimental_bar, + }; + + Ok(InvoiceRequestContents { inner, payer_signing_pubkey }) } } @@ -1505,6 +1784,15 @@ pub struct InvoiceRequestFields { /// The Human Readable Name which the sender indicated they were paying to. pub human_readable_name: Option, + + /// Recurrence fields copied from the invoice request when the request belongs to a recurring + /// offer. + /// + /// `counter` identifies the payer's recurring request number, `start` identifies the schedule + /// offset when the offer has an explicit basetime, `cancel` distinguishes cancellation + /// requests from normal recurring requests, and the speculative `token` echoes opaque bytes + /// from the previous invoice. + pub invoice_request_recurrence: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1517,11 +1805,22 @@ pub const PAYER_NOTE_LIMIT: usize = 8; impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let (recurrence_counter, recurrence_start, recurrence_token, recurrence_cancel) = + match self.invoice_request_recurrence.as_ref() { + None => (None, None, None, None), + Some(rec) => rec.fields(), + }; + write_tlv_fields!(writer, { (0, self.payer_signing_pubkey, required), (1, self.human_readable_name, option), (2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option), (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), + (6, recurrence_counter.map(HighZeroBytesDroppedBigSize), option), + (8, recurrence_start.map(HighZeroBytesDroppedBigSize), option), + (10, recurrence_cancel, option), + // Speculative request-side recurrence token, distinct from the future invoice-side token. + (12, recurrence_token.map(|token| WithoutLength(token)), option), }); Ok(()) } @@ -1534,13 +1833,26 @@ impl Readable for InvoiceRequestFields { (1, human_readable_name, option), (2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))), (4, payer_note_truncated, (option, encoding: (String, WithoutLength))), + (6, recurrence_counter, (option, encoding: (u32, HighZeroBytesDroppedBigSize))), + (8, recurrence_start, (option, encoding: (u32, HighZeroBytesDroppedBigSize))), + (10, recurrence_cancel, option), + (12, recurrence_token, (option, encoding: (Vec, WithoutLength))), }); + let invoice_request_recurrence = InvoiceRequestRecurrence::new( + recurrence_counter, + recurrence_start, + recurrence_token, + recurrence_cancel, + ) + .map_err(|_| DecodeError::InvalidValue)?; + Ok(InvoiceRequestFields { payer_signing_pubkey: payer_signing_pubkey.0.unwrap(), quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + invoice_request_recurrence, }) } } @@ -1549,8 +1861,9 @@ impl Readable for InvoiceRequestFields { mod tests { use super::{ ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequest, InvoiceRequestFields, - InvoiceRequestTlvStreamRef, UnsignedInvoiceRequest, EXPERIMENTAL_INVOICE_REQUEST_TYPES, - INVOICE_REQUEST_TYPES, PAYER_NOTE_LIMIT, SIGNATURE_TAG, + InvoiceRequestRecurrence, InvoiceRequestTlvStreamRef, PartialInvoiceRequestTlvStreamRef, + UnsignedInvoiceRequest, WithOfferBasetimeRecurrence, WithoutOfferBasetimeRecurrence, + EXPERIMENTAL_INVOICE_REQUEST_TYPES, INVOICE_REQUEST_TYPES, PAYER_NOTE_LIMIT, SIGNATURE_TAG, }; use crate::ln::channelmanager::PaymentId; @@ -1565,7 +1878,8 @@ mod tests { #[cfg(c_bindings)] use crate::offers::offer::OfferWithExplicitMetadataBuilder as OfferBuilder; use crate::offers::offer::{ - Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity, + Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, Offer, OfferTlvStreamRef, Quantity, + RecurrenceBase, RecurrencePeriod, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::payer::PayerTlvStreamRef; @@ -1580,6 +1894,26 @@ mod tests { #[cfg(feature = "std")] use core::time::Duration; + trait ToBytes { + fn to_bytes(&self) -> Vec; + } + + impl<'a> ToBytes for (OfferTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef) { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + + impl<'a> ToBytes for PartialInvoiceRequestTlvStreamRef<'a> { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + #[test] fn builds_invoice_request_with_defaults() { let expanded_key = ExpandedKey::new([42; 32]); @@ -1647,6 +1981,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 +1996,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -2920,6 +3263,143 @@ mod tests { } } + #[test] + fn parses_invoice_request_with_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_period = RecurrencePeriod::Months(3); + let recurrence_base = RecurrenceBase { proportional: true, basetime: 123_456 }; + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.0.recurrence_base = Some(&recurrence_base); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let recurrence = InvoiceRequestRecurrence::WithOfferBasetime(WithOfferBasetimeRecurrence { + counter: 3, + start: 2, + token: Some(vec![1, 2, 3]), + cancel: Some(()), + }); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .set_invoice_request_recurrence(recurrence.clone()) + .build_and_sign() + .unwrap(); + + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + + match InvoiceRequest::try_from(buffer) { + Ok(invoice_request) => { + assert_eq!(invoice_request.invoice_request_recurrence(), &Some(recurrence)); + }, + Err(e) => panic!("error parsing invoice_request: {:?}", e), + } + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let recurrence = + InvoiceRequestRecurrence::WithoutOfferBasetime(WithoutOfferBasetimeRecurrence { + counter: 2, + token: Some(vec![4, 5, 6]), + cancel: Some(()), + }); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .set_invoice_request_recurrence(recurrence.clone()) + .build_and_sign() + .unwrap(); + + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + + match InvoiceRequest::try_from(buffer) { + Ok(invoice_request) => { + assert_eq!(invoice_request.invoice_request_recurrence(), &Some(recurrence)); + }, + Err(e) => panic!("error parsing invoice_request: {:?}", e), + } + } + + #[test] + fn fails_parsing_invoice_request_with_invalid_recurrence() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + let recurrence_period = RecurrencePeriod::Months(1); + let recurrence_base = RecurrenceBase { proportional: false, basetime: 123_456 }; + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.0.recurrence_base = Some(&recurrence_base); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .set_invoice_request_recurrence(InvoiceRequestRecurrence::WithOfferBasetime( + WithOfferBasetimeRecurrence { counter: 1, start: 1, token: None, cancel: None }, + )) + .build_and_sign() + .unwrap(); + let recurrence_token = vec![42; 3]; + let mut tlv_stream = invoice_request.contents.as_tlv_stream(); + tlv_stream.2.recurrence_counter = Some(0); + tlv_stream.2.recurrence_start = Some(1); + tlv_stream.2.recurrence_token = Some(&recurrence_token); + + match InvoiceRequest::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + let offer = Offer::try_from(tlv_stream.to_bytes()).unwrap(); + + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1000) + .unwrap() + .build_and_sign() + .unwrap(); + let mut tlv_stream = invoice_request.contents.as_tlv_stream(); + tlv_stream.2.recurrence_counter = Some(1); + tlv_stream.2.recurrence_start = Some(0); + + match InvoiceRequest::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ), + } + } + #[test] fn parses_invoice_request_with_experimental_tlv_records() { let expanded_key = ExpandedKey::new([42; 32]); @@ -3118,6 +3598,7 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + invoice_request_recurrence: None, } ); diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..45c8ccbf906 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), + offer_recurrence: None, #[cfg(test)] experimental_foo: None, }, @@ -301,6 +302,7 @@ macro_rules! offer_derived_metadata_builder_methods { paths: None, supported_quantity: Quantity::One, issuer_signing_pubkey: Some(node_id), + offer_recurrence: None, #[cfg(test)] experimental_foo: None, }, @@ -632,10 +634,346 @@ pub(super) struct OfferContents { paths: Option>, supported_quantity: Quantity, issuer_signing_pubkey: Option, + offer_recurrence: Option, #[cfg(test)] experimental_foo: Option, } +/// Represents the recurrence period as `(time_unit, count)`. +/// +/// The full duration of a recurrence period is defined by combining +/// [`time_unit`](Self::time_unit) with [`period`](Self::period). +/// +/// For example, `TimeUnit::Days` with `period = 7` represents a recurrence +/// every seven days. +// +// Implementation Note: +// The current spec design feels a bit non-optimal, as it requires both +// an enum and a struct to represent what is conceptually a single "period". +// Might revisit once the spec stabilizes. +// +// Spec Commentary: +// The naming around "period" and "time_unit" is slightly confusing. +// For example, `period means count_of_units`, while the actual recurrence +// "period" is `(period * time_unit)`. +// +// It may help the final spec to create clearer names for each variable. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecurrencePeriod { + /// A recurrence period measured in whole seconds. + Seconds(u32), + /// A recurrence period measured in whole days. + Days(u32), + /// A recurrence period measured in whole calendar months. + Months(u32), +} + +impl RecurrencePeriod { + const SECONDS_PER_DAY: u64 = 86_400; + + fn start_time(self, basetime: u64, period_count: u32) -> Result { + match self { + RecurrencePeriod::Seconds(seconds) => { + let offset = u64::from(seconds).checked_mul(period_count.into()).ok_or(())?; + basetime.checked_add(offset).ok_or(()) + }, + + RecurrencePeriod::Days(days) => { + let days_since_epoch = basetime / Self::SECONDS_PER_DAY; + let seconds = basetime % Self::SECONDS_PER_DAY; + + let offset_days = u64::from(days).checked_mul(period_count.into()).ok_or(())?; + let start_day = days_since_epoch.checked_add(offset_days).ok_or(())?; + + start_day + .checked_mul(Self::SECONDS_PER_DAY) + .and_then(|day_seconds| day_seconds.checked_add(seconds)) + .ok_or(()) + }, + + RecurrencePeriod::Months(months) => { + let days_since_epoch = basetime / Self::SECONDS_PER_DAY; + let seconds = basetime % Self::SECONDS_PER_DAY; + + let (year, month, day) = civil_from_days(days_since_epoch as i128); + + let offset_months = i128::from(months).checked_mul(period_count.into()).ok_or(())?; + + let total_months = year + .checked_mul(12) + .and_then(|year_in_months| year_in_months.checked_add(month as i128 - 1)) + .and_then(|base_month| base_month.checked_add(offset_months)) + .ok_or(())?; + + let target_year = total_months.div_euclid(12); + let target_month = total_months.rem_euclid(12) + 1; + let target_day = day.min(days_in_month(target_year, target_month as u64)); + + let start_day = days_from_civil(target_year, target_month as u64, target_day); + + u64::try_from(start_day) + .ok() + .and_then(|start_day| start_day.checked_mul(Self::SECONDS_PER_DAY)) + .and_then(|day_seconds| day_seconds.checked_add(seconds)) + .ok_or(()) + }, + } + } +} + +fn is_leap_year(year: i128) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + +fn days_in_month(year: i128, month: u64) -> u64 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if is_leap_year(year) => 29, + 2 => 28, + _ => panic!("invalid month"), + } +} + +fn days_from_civil(year: i128, month: u64, day: u64) -> i128 { + assert!((1..=12).contains(&month)); + assert!((1..=days_in_month(year, month)).contains(&day)); + + let mut y = year; + let m = month as i128; + let d = day as i128; + + if m <= 2 { + y -= 1; + } + + let era = y.div_euclid(400); + let yoe = y - era * 400; + let mp = m + if m > 2 { -3 } else { 9 }; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + + era * 146_097 + doe - 719_468 +} + +fn civil_from_days(days: i128) -> (i128, u64, u64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + + if month <= 2 { + year += 1; + } + + (year, month as u64, day as u64) +} + +impl Writeable for RecurrencePeriod { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let (tag, period) = match self { + RecurrencePeriod::Seconds(p) => (0u8, p), + RecurrencePeriod::Days(p) => (1u8, p), + RecurrencePeriod::Months(p) => (2u8, p), + }; + + tag.write(writer)?; + HighZeroBytesDroppedBigSize(*period).write(writer) + } +} + +impl Readable for RecurrencePeriod { + fn read(r: &mut R) -> Result { + let time_unit_byte: u8 = Readable::read(r)?; + let period: HighZeroBytesDroppedBigSize = Readable::read(r)?; + + if period.0 == 0 { + return Err(DecodeError::InvalidValue); + } + + match time_unit_byte { + 0 => Ok(Self::Seconds(period.0)), + 1 => Ok(Self::Days(period.0)), + 2 => Ok(Self::Months(period.0)), + _ => Err(DecodeError::InvalidValue), + } + } +} + +/// Represents the base time from which recurrence periods are anchored. +/// +/// Example: +/// If an offer sets its basetime to Jan 1st, then the first recurrence +/// period is defined as starting on Jan 1st. +/// A payer starting on April 1st would begin at offset 3. +/// +/// If this field is absent from the offer, the protocol defines the start of +/// period 0 using the `invoice_created_at` timestamp of the first invoice in +/// the recurrence series. +// +// Spec Commentary: +// The presence of `proportional` here feels conceptually odd. +// It mixes two different ideas: +// 1. The *start anchor* of the recurrence schedule (`basetime`) +// 2. A *pricing policy* based on how far into the period the payer is +// +// It also raises questions: +// - Why is proportionality tied to basetime? +// - Why can’t proportional pricing exist without an explicit basetime? +// (It would make sense from the second period onward, where the +// schedule is already well-defined.) +// +// Might be worth revisiting the grouping of these fields in the final spec. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceBase { + /// If true, price is proportional to how much of the period has passed. + /// + /// Example: + /// For a 30-day period, paying 3 days after the start yields ~10% discount. + pub proportional: bool, + + /// Basetime expressed in UNIX seconds. + pub basetime: u64, +} + +impl Writeable for RecurrenceBase { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + (self.proportional as u8).write(writer)?; + HighZeroBytesDroppedBigSize(self.basetime).write(writer) + } +} + +impl Readable for RecurrenceBase { + fn read(r: &mut R) -> Result { + let proportional_byte: u8 = Readable::read(r)?; + let proportional = match proportional_byte { + 0 => false, + 1 => true, + _ => return Err(DecodeError::InvalidValue), + }; + + let basetime: HighZeroBytesDroppedBigSize = Readable::read(r)?; + + Ok(RecurrenceBase { proportional, basetime: basetime.0 }) + } +} + +/// Acceptance paywindow for a recurrence period. +/// Defines the time around the *start of a period* during which a payer's +/// payment SHOULD (not MUST) be accepted. +/// +/// If this field is absent, the default window is: +/// - the entire previous period, PLUS +/// - the entire current period being paid for. +// +// Spec Commentary: +// The use of SHOULD (instead of MUST) is unclear. +// What specific flexibility is intended here, and what behavior is expected +// from implementations outside the window? +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrencePaywindow { + /// Seconds *before* the period starts in which a payment SHOULD be allowed. + pub seconds_before: u32, + /// Seconds *after* the period starts in which a payment SHOULD be allowed. + pub seconds_after: u32, +} + +impl Writeable for RecurrencePaywindow { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + self.seconds_before.write(writer)?; + self.seconds_after.write(writer) + } +} + +impl Readable for RecurrencePaywindow { + fn read(r: &mut R) -> Result { + let before = Readable::read(r)?; + let after = Readable::read(r)?; + Ok(RecurrencePaywindow { seconds_before: before, seconds_after: after }) + } +} + +/// Maximum number of recurrence periods allowed for this offer. +/// +/// Counting always begins from the offer’s recurrence start: +/// - If `recurrence_base` is set, counting starts from that basetime. +/// - If it is not set, counting starts from the time the first invoice is created. +/// +/// This value is a count, not a zero-based index. For example, `RecurrenceLimit(5)` permits +/// period indices `0..=4` and rejects period index `5`. +/// +/// After this limit is reached, further payments MUST NOT be accepted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceLimit(pub u32); + +impl Writeable for RecurrenceLimit { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + HighZeroBytesDroppedBigSize(self.0).write(writer) + } +} + +impl Readable for RecurrenceLimit { + fn read(r: &mut R) -> Result { + let value: HighZeroBytesDroppedBigSize = Readable::read(r)?; + if value.0 == 0 { + return Err(DecodeError::InvalidValue); + } + Ok(RecurrenceLimit(value.0)) + } +} + +/// Represents the recurrence-related fields in an Offer. +/// +/// The recurrence schedule itself is shared by both wire variants: +/// `offer_recurrence_optional` and `offer_recurrence_compulsory`. +/// `RecurrenceType` preserves which variant the offer used, while +/// `recurrence_paywindow` and `recurrence_limit` apply to either form. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RecurrenceFields { + /// The recurrence schedule: period length and unit. + pub recurrence_period: RecurrencePeriod, + /// The allowed early/late window for paying a given period. + pub recurrence_paywindow: Option, + /// Maximum number of periods allowed for this Offer. + pub recurrence_limit: Option, +} + +/// Encodes the recurrence schedule and whether recurrence is optional or compulsory. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recurrence { + /// Recurrence is required, optionally anchored by an explicit period-0 base. + Compulsory { + /// Explicit base time for period 0 when the offer defines one. + base: Option, + /// Fields shared by both recurrence encodings. + fields: RecurrenceFields, + }, + + /// Recurrence is supported but not required for payment. + Optional { + /// Fields shared by both recurrence encodings. + fields: RecurrenceFields, + }, +} + +/// Encodes whether a recurring offer is optional or compulsory for the payer. +/// +/// Compulsory recurrence may optionally define an explicit period-0 basetime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RecurrenceType { + /// Recurrence is optional, so pre-recurrence payers may still attempt a + /// single payment. + Optional, + /// Recurrence is required for this offer. The optional basetime anchors + /// period 0 when the offer defines one explicitly. + Compulsory(Option), +} + macro_rules! offer_accessors { ($self: ident, $contents: expr) => { // TODO: Return a slice once ChainHash has constants. // - https://github.com/rust-bitcoin/rust-bitcoin/pull/1283 @@ -708,6 +1046,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 offer_recurrence(&$self) -> Option<$crate::offers::offer::Recurrence> { + $contents.offer_recurrence() + } } } impl Offer { @@ -993,6 +1336,10 @@ impl OfferContents { self.issuer_signing_pubkey } + pub fn offer_recurrence(&self) -> Option { + self.offer_recurrence + } + pub(super) fn verify_using_metadata( &self, bytes: &[u8], key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result<(OfferId, Option), ()> { @@ -1060,6 +1407,31 @@ impl OfferContents { } }; + let ( + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, + ) = match &self.offer_recurrence { + None => (None, None, None, None, None), + + Some(Recurrence::Compulsory { base, fields }) => ( + Some(&fields.recurrence_period), + None, + base.as_ref(), + fields.recurrence_paywindow.as_ref(), + fields.recurrence_limit.as_ref(), + ), + Some(Recurrence::Optional { fields }) => ( + None, + Some(&fields.recurrence_period), + None, + fields.recurrence_paywindow.as_ref(), + fields.recurrence_limit.as_ref(), + ), + }; + let offer = OfferTlvStreamRef { chains: self.chains.as_ref(), metadata: self.metadata(), @@ -1072,6 +1444,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 +1603,38 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef<'a>, OFFER_TYPES, { (18, issuer: (String, WithoutLength)), (20, quantity_max: (u64, HighZeroBytesDroppedBigSize)), (OFFER_ISSUER_ID_TYPE, issuer_id: PublicKey), + + // --- Recurrence Fields (as described in BOLT12 recurrence) --- + // These comments are for implementation clarity and will be refined later. + + // (24) `recurrence_compulsory` + // Offer *requires* recurrence. + // Payer must understand and follow the recurrence schedule. + // Encodes the recurrence period (monthly, weekly, etc). + (24, recurrence_compulsory: RecurrencePeriod), + + // (25) `recurrence_optional` + // Offer *supports* recurrence but doesn't require it. + // Payers without recurrence support can treat it as a single-payment offer. + // Encodes the recurrence period. + (25, recurrence_optional: RecurrencePeriod), + + // (26) `recurrence_base` + // Start anchor ("base time") for the recurrence schedule. + // If absent: defaults to timestamp of the first invoice creation. + // Only meaningful when recurrence is compulsory. + (26, recurrence_base: RecurrenceBase), + + // (27) `recurrence_paywindow` + // Window around each period’s due time in which the payer SHOULD pay. + // If absent: default window is previous period + current period. + // Useful for handling early/late payments reliably. + (27, recurrence_paywindow: RecurrencePaywindow), + + // (29) `recurrence_limit` + // Maximum number of periods this offer can be paid for. + // Caps the total count of recurring payments. + (29, recurrence_limit: RecurrenceLimit), }); /// Valid type range for experimental offer TLV records. @@ -1295,6 +1704,11 @@ impl TryFrom for OfferContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -1342,6 +1756,33 @@ impl TryFrom for OfferContents { (issuer_id, paths) => (issuer_id, paths), }; + let offer_recurrence = match (recurrence_compulsory, recurrence_optional, recurrence_base) { + (None, None, None) => { + if recurrence_paywindow.is_some() || recurrence_limit.is_some() { + return Err(Bolt12SemanticError::InvalidRecurrence); + } + None + }, + (Some(recurrence_period), None, base) => Some(Recurrence::Compulsory { + base, + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow, + recurrence_limit, + }, + }), + + (None, Some(recurrence_period), None) => Some(Recurrence::Optional { + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow, + recurrence_limit, + }, + }), + + _ => return Err(Bolt12SemanticError::InvalidRecurrence), + }; + Ok(OfferContents { chains, metadata, @@ -1353,6 +1794,7 @@ impl TryFrom for OfferContents { paths, supported_quantity, issuer_signing_pubkey, + offer_recurrence, #[cfg(test)] experimental_foo, }) @@ -1386,8 +1828,9 @@ mod tests { #[cfg(c_bindings)] use super::OfferWithExplicitMetadataBuilder as OfferBuilder; use super::{ - Amount, ExperimentalOfferTlvStreamRef, Offer, OfferTlvStreamRef, Quantity, - EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + days_from_civil, Amount, ExperimentalOfferTlvStreamRef, FullOfferTlvStreamRef, Offer, + OfferTlvStreamRef, Quantity, Recurrence, RecurrenceBase, RecurrenceFields, RecurrenceLimit, + RecurrencePaywindow, RecurrencePeriod, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::blinded_path::message::BlindedMessagePath; @@ -1408,6 +1851,27 @@ mod tests { use core::num::NonZeroU64; use core::time::Duration; + trait ToBytes { + fn to_bytes(&self) -> Vec; + } + + impl<'a> ToBytes for FullOfferTlvStreamRef<'a> { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + + fn unix_time(year: i128, month: u64, day: u64, hour: u64, minute: u64, second: u64) -> u64 { + let days_since_epoch = days_from_civil(year, month, day); + u64::try_from(days_since_epoch) + .unwrap() + .checked_mul(RecurrencePeriod::SECONDS_PER_DAY) + .and_then(|days| days.checked_add(hour * 3600 + minute * 60 + second)) + .unwrap() + } + #[test] fn builds_offer_with_defaults() { let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); @@ -1430,6 +1894,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.offer_recurrence(), None); assert_eq!( offer.as_tlv_stream(), @@ -1446,6 +1911,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 }, ), @@ -1456,6 +1926,109 @@ mod tests { } } + #[test] + fn parses_offer_with_compulsory_recurrence() { + let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let recurrence_period = RecurrencePeriod::Months(3); + let recurrence_base = RecurrenceBase { proportional: true, basetime: 123_456 }; + let recurrence_paywindow = + RecurrencePaywindow { seconds_before: 3600, seconds_after: 7200 }; + let recurrence_limit = RecurrenceLimit(24); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.0.recurrence_base = Some(&recurrence_base); + tlv_stream.0.recurrence_paywindow = Some(&recurrence_paywindow); + tlv_stream.0.recurrence_limit = Some(&recurrence_limit); + + match Offer::try_from(tlv_stream.to_bytes()) { + Ok(parsed_offer) => { + assert_eq!( + parsed_offer.offer_recurrence(), + Some(Recurrence::Compulsory { + base: Some(recurrence_base), + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow: Some(recurrence_paywindow), + recurrence_limit: Some(recurrence_limit), + }, + }), + ); + }, + Err(e) => panic!("error parsing offer: {:?}", e), + } + } + + #[test] + fn parses_offer_with_optional_recurrence() { + let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let recurrence_period = RecurrencePeriod::Days(14); + let recurrence_paywindow = + RecurrencePaywindow { seconds_before: 1800, seconds_after: 5400 }; + let recurrence_limit = RecurrenceLimit(12); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + tlv_stream.0.recurrence_paywindow = Some(&recurrence_paywindow); + tlv_stream.0.recurrence_limit = Some(&recurrence_limit); + + match Offer::try_from(tlv_stream.to_bytes()) { + Ok(parsed_offer) => { + assert_eq!( + parsed_offer.offer_recurrence(), + Some(Recurrence::Optional { + fields: RecurrenceFields { + recurrence_period, + recurrence_paywindow: Some(recurrence_paywindow), + recurrence_limit: Some(recurrence_limit), + }, + }), + ); + }, + Err(e) => panic!("error parsing offer: {:?}", e), + } + } + + #[test] + fn fails_parsing_offer_with_invalid_recurrence_fields() { + let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let recurrence_compulsory = RecurrencePeriod::Months(1); + let recurrence_optional = RecurrencePeriod::Days(7); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_compulsory = Some(&recurrence_compulsory); + tlv_stream.0.recurrence_optional = Some(&recurrence_optional); + + match Offer::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidRecurrence) + ); + }, + } + } + + #[test] + fn calculates_recurrence_start_time_for_seconds() { + let start_time = RecurrencePeriod::Seconds(600).start_time(1_234, 3); + assert_eq!(start_time, Ok(3_034)); + } + + #[test] + fn calculates_recurrence_start_time_for_days() { + let basetime = unix_time(2024, 1, 15, 1, 2, 3); + let start_time = RecurrencePeriod::Days(2).start_time(basetime, 3); + + assert_eq!(start_time, Ok(unix_time(2024, 1, 21, 1, 2, 3))); + } + + #[test] + fn calculates_recurrence_start_time_for_months() { + let basetime = unix_time(2024, 1, 15, 1, 2, 3); + let start_time = RecurrencePeriod::Months(1).start_time(basetime, 2); + + assert_eq!(start_time, Ok(unix_time(2024, 3, 15, 1, 2, 3))); + } + #[test] fn builds_offer_with_chains() { let mainnet = ChainHash::using_genesis_block(Network::Bitcoin); diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs index df71e860d2d..e3b6e13c32e 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -233,6 +233,10 @@ pub enum Bolt12SemanticError { /// /// [`Refund`]: super::refund::Refund UnexpectedHumanReadableName, + /// Recurrence was not expected but present. + UnexpectedRecurrence, + /// Recurrence was present but contains invalid values. + InvalidRecurrence, } impl From for Bolt12ParseError { diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..cb0c995f5a1 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -782,6 +782,11 @@ impl RefundContents { issuer: self.issuer.as_ref(), quantity_max: None, issuer_id: None, + recurrence_compulsory: None, + recurrence_optional: None, + recurrence_base: None, + recurrence_paywindow: None, + recurrence_limit: None, }; let features = { @@ -801,6 +806,10 @@ impl RefundContents { payer_note: self.payer_note.as_ref(), paths: self.paths.as_ref(), offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }; let experimental_offer = ExperimentalOfferTlvStreamRef { @@ -911,6 +920,11 @@ impl TryFrom for RefundContents { issuer, quantity_max, issuer_id, + recurrence_compulsory, + recurrence_optional, + recurrence_base, + recurrence_paywindow, + recurrence_limit, }, InvoiceRequestTlvStream { chain, @@ -921,6 +935,10 @@ impl TryFrom for RefundContents { payer_note, paths, offer_from_hrn, + recurrence_counter, + recurrence_start, + recurrence_cancel, + recurrence_token: _, }, ExperimentalOfferTlvStream { #[cfg(test)] @@ -972,11 +990,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 => { @@ -1042,7 +1074,10 @@ mod tests { EXPERIMENTAL_INVOICE_REQUEST_TYPES, INVOICE_REQUEST_TYPES, }; use crate::offers::nonce::Nonce; - use crate::offers::offer::{ExperimentalOfferTlvStreamRef, OfferTlvStreamRef}; + use crate::offers::offer::{ + ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, RecurrenceBase, RecurrenceLimit, + RecurrencePaywindow, RecurrencePeriod, + }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::payer::PayerTlvStreamRef; use crate::offers::test_utils::*; @@ -1101,6 +1136,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, @@ -1111,6 +1151,10 @@ mod tests { payer_note: None, paths: None, offer_from_hrn: None, + recurrence_counter: None, + recurrence_start: None, + recurrence_cancel: None, + recurrence_token: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, @@ -1721,6 +1765,51 @@ mod tests { } } + #[test] + fn fails_parsing_refund_with_unexpected_recurrence_fields() { + let refund = + RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap().build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let recurrence_period = RecurrencePeriod::Months(1); + let recurrence_base = RecurrenceBase { proportional: false, basetime: 123_456 }; + let recurrence_paywindow = + RecurrencePaywindow { seconds_before: 3600, seconds_after: 3600 }; + let recurrence_limit = RecurrenceLimit(4); + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.recurrence_compulsory = Some(&recurrence_period); + tlv_stream.1.recurrence_base = Some(&recurrence_base); + tlv_stream.1.recurrence_paywindow = Some(&recurrence_paywindow); + tlv_stream.1.recurrence_limit = Some(&recurrence_limit); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ); + }, + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.2.recurrence_counter = Some(1); + tlv_stream.2.recurrence_start = Some(2); + tlv_stream.2.recurrence_cancel = Some(&()); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ); + }, + } + } + #[test] fn parses_refund_with_unknown_tlv_records() { const UNKNOWN_ODD_TYPE: u64 = INVOICE_REQUEST_TYPES.end - 1; diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index c8afb7cfc12..0a68c5572ba 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -129,6 +129,10 @@ impl<'a> StaticInvoiceBuilder<'a> { return Err(Bolt12SemanticError::UnexpectedChain); } + if offer.offer_recurrence().is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + if payment_paths.is_empty() || held_htlc_available_paths.is_empty() || offer.paths().is_empty() @@ -483,6 +487,8 @@ impl InvoiceContents { node_id: Some(&self.signing_pubkey), amount: None, payment_hash: None, + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }; let experimental_invoice = ExperimentalInvoiceTlvStreamRef { @@ -682,6 +688,8 @@ impl TryFrom for InvoiceContents { held_htlc_available_paths, payment_hash, amount, + invoice_recurrence_basetime, + invoice_recurrence_token, }, experimental_offer_tlv_stream, ExperimentalInvoiceTlvStream { @@ -697,6 +705,10 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::UnexpectedAmount); } + if invoice_recurrence_basetime.is_some() || invoice_recurrence_token.is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + let payment_paths = construct_payment_paths(blindedpay, paths)?; let held_htlc_available_paths = held_htlc_available_paths.ok_or(Bolt12SemanticError::MissingPaths)?; @@ -720,8 +732,14 @@ impl TryFrom for InvoiceContents { return Err(Bolt12SemanticError::UnexpectedChain); } + let offer = OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?; + + if offer.offer_recurrence().is_some() { + return Err(Bolt12SemanticError::UnexpectedRecurrence); + } + Ok(InvoiceContents { - offer: OfferContents::try_from((offer_tlv_stream, experimental_offer_tlv_stream))?, + offer, payment_paths, held_htlc_available_paths, created_at, @@ -750,6 +768,7 @@ mod tests { use crate::offers::nonce::Nonce; use crate::offers::offer::{ ExperimentalOfferTlvStreamRef, Offer, OfferBuilder, OfferTlvStreamRef, Quantity, + RecurrencePeriod, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::static_invoice::{ @@ -918,6 +937,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( @@ -932,6 +956,8 @@ mod tests { features: None, node_id: Some(&signing_pubkey), held_htlc_available_paths: Some(&paths), + invoice_recurrence_basetime: None, + invoice_recurrence_token: None, }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, @@ -1652,6 +1678,39 @@ mod tests { } } + #[test] + fn fails_parsing_invoice_with_recurrence() { + let invoice = invoice(); + let recurrence_token = vec![1, 2, 3]; + let mut tlv_stream = invoice.as_tlv_stream(); + tlv_stream.1.invoice_recurrence_basetime = Some(123_456); + tlv_stream.1.invoice_recurrence_token = Some(&recurrence_token); + + match StaticInvoice::try_from(tlv_stream_to_bytes(&tlv_stream)) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ), + } + } + + #[test] + fn fails_parsing_invoice_with_offer_recurrence() { + let invoice = invoice(); + let recurrence_period = RecurrencePeriod::Days(14); + let mut tlv_stream = invoice.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + + match StaticInvoice::try_from(tlv_stream_to_bytes(&tlv_stream)) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnexpectedRecurrence) + ), + } + } + #[test] fn fails_parsing_invoice_with_invalid_offer_fields() { // Error if the offer is missing paths. @@ -1719,4 +1778,41 @@ mod tests { assert_eq!(invoice.offer_id(), offer_id); } + + #[test] + fn fails_building_invoice_from_offer_with_recurrence() { + let node_id = recipient_pubkey(); + let now = now(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + + let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) + .path(blinded_path()) + .build() + .unwrap(); + + let recurrence_period = RecurrencePeriod::Days(14); + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.0.recurrence_optional = Some(&recurrence_period); + + let mut offer_bytes = Vec::new(); + tlv_stream.0.write(&mut offer_bytes).unwrap(); + tlv_stream.1.write(&mut offer_bytes).unwrap(); + let offer = Offer::try_from(offer_bytes).unwrap(); + + match StaticInvoiceBuilder::for_offer_using_derived_keys( + &offer, + payment_paths(), + vec![blinded_path()], + now, + &expanded_key, + nonce, + &secp_ctx, + ) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::UnexpectedRecurrence), + } + } }