diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 90f6c183c0e..e3fe112112e 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -9,13 +9,14 @@ //! Data structures and encoding for `invoice_request` messages. //! -//! An [`InvoiceRequest`] can be either built from a parsed [`Offer`] as an "offer to be paid" or -//! built directly as an "offer for money" (e.g., refund, ATM withdrawal). In the former case, it is +//! An [`InvoiceRequest`] can be built from a parsed [`Offer`] as an "offer to be paid". It is //! typically constructed by a customer and sent to the merchant who had published the corresponding -//! offer. In the latter case, an offer doesn't exist as a precursor to the request. Rather the -//! merchant would typically construct the invoice request and present it to the customer. +//! offer. The recipient of the request responds with an `Invoice`. //! -//! The recipient of the request responds with an `Invoice`. +//! For an "offer for money" (e.g., refund, ATM withdrawal), where an offer doesn't exist as a +//! precursor, see [`Refund`]. +//! +//! [`Refund`]: crate::offers::refund::Refund //! //! ```ignore //! extern crate bitcoin; @@ -34,7 +35,6 @@ //! let pubkey = PublicKey::from(keys); //! let mut buffer = Vec::new(); //! -//! // "offer to be paid" flow //! "lno1qcp4256ypq" //! .parse::()? //! .request_invoice(vec![42; 64], pubkey)? @@ -287,7 +287,7 @@ impl InvoiceRequest { self.contents.amount_msats } - /// Features for paying the invoice. + /// Features pertaining to requesting an invoice. pub fn features(&self) -> &InvoiceRequestFeatures { &self.contents.features } @@ -471,7 +471,7 @@ impl TryFrom for InvoiceRequestContents { #[cfg(test)] mod tests { - use super::InvoiceRequest; + use super::{InvoiceRequest, InvoiceRequestTlvStreamRef}; use bitcoin::blockdata::constants::ChainHash; use bitcoin::network::constants::Network; @@ -483,9 +483,10 @@ mod tests { use core::time::Duration; use crate::ln::features::InvoiceRequestFeatures; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; - use crate::offers::merkle::SignError; - use crate::offers::offer::{Amount, OfferBuilder, Quantity}; + use crate::offers::merkle::{SignError, SignatureTlvStreamRef}; + use crate::offers::offer::{Amount, OfferBuilder, OfferTlvStreamRef, Quantity}; use crate::offers::parse::{ParseError, SemanticError}; + use crate::offers::payer::PayerTlvStreamRef; use crate::util::ser::{BigSize, Writeable}; use crate::util::string::PrintableString; @@ -517,14 +518,13 @@ mod tests { #[test] fn builds_invoice_request_with_defaults() { - let offer = OfferBuilder::new("foo".into(), recipient_pubkey()) + let invoice_request = OfferBuilder::new("foo".into(), recipient_pubkey()) .amount_msats(1000) - .build().unwrap(); - let invoice_request = offer.request_invoice(vec![1; 32], payer_pubkey()).unwrap() - .build().unwrap().sign(payer_sign).unwrap(); + .build().unwrap() + .request_invoice(vec![1; 32], payer_pubkey()).unwrap() + .build().unwrap() + .sign(payer_sign).unwrap(); - let (payer_tlv_stream, offer_tlv_stream, invoice_request_tlv_stream, signature_tlv_stream) = - invoice_request.as_tlv_stream(); let mut buffer = Vec::new(); invoice_request.write(&mut buffer).unwrap(); @@ -538,25 +538,34 @@ mod tests { assert_eq!(invoice_request.payer_note(), None); assert!(invoice_request.signature().is_some()); - assert_eq!(payer_tlv_stream.metadata, Some(&vec![1; 32])); - assert_eq!(offer_tlv_stream.chains, None); - assert_eq!(offer_tlv_stream.metadata, None); - assert_eq!(offer_tlv_stream.currency, None); - assert_eq!(offer_tlv_stream.amount, Some(1000)); - assert_eq!(offer_tlv_stream.description, Some(&String::from("foo"))); - assert_eq!(offer_tlv_stream.features, None); - assert_eq!(offer_tlv_stream.absolute_expiry, None); - assert_eq!(offer_tlv_stream.paths, None); - assert_eq!(offer_tlv_stream.issuer, None); - assert_eq!(offer_tlv_stream.quantity_max, None); - assert_eq!(offer_tlv_stream.node_id, Some(&recipient_pubkey())); - assert_eq!(invoice_request_tlv_stream.chain, None); - assert_eq!(invoice_request_tlv_stream.amount, None); - assert_eq!(invoice_request_tlv_stream.features, None); - assert_eq!(invoice_request_tlv_stream.quantity, None); - assert_eq!(invoice_request_tlv_stream.payer_id, Some(&payer_pubkey())); - assert_eq!(invoice_request_tlv_stream.payer_note, None); - assert!(signature_tlv_stream.signature.is_some()); + assert_eq!( + invoice_request.as_tlv_stream(), + ( + PayerTlvStreamRef { metadata: Some(&vec![1; 32]) }, + OfferTlvStreamRef { + chains: None, + metadata: None, + currency: None, + amount: Some(1000), + description: Some(&String::from("foo")), + features: None, + absolute_expiry: None, + paths: None, + issuer: None, + quantity_max: None, + node_id: Some(&recipient_pubkey()), + }, + InvoiceRequestTlvStreamRef { + chain: None, + amount: None, + features: None, + quantity: None, + payer_id: Some(&payer_pubkey()), + payer_note: None, + }, + SignatureTlvStreamRef { signature: invoice_request.signature().as_ref() }, + ), + ); if let Err(e) = InvoiceRequest::try_from(buffer) { panic!("error parsing invoice request: {:?}", e); diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index be0eb2da522..11df5ca1f8a 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -17,3 +17,4 @@ mod merkle; pub mod offer; pub mod parse; mod payer; +pub mod refund; diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 680f4094162..6451d9431a1 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -106,7 +106,7 @@ impl OfferBuilder { let offer = OfferContents { chains: None, metadata: None, amount: None, description, features: OfferFeatures::empty(), absolute_expiry: None, issuer: None, paths: None, - supported_quantity: Quantity::one(), signing_pubkey: Some(signing_pubkey), + supported_quantity: Quantity::one(), signing_pubkey, }; OfferBuilder { offer } } @@ -263,7 +263,7 @@ pub(super) struct OfferContents { issuer: Option, paths: Option>, supported_quantity: Quantity, - signing_pubkey: Option, + signing_pubkey: PublicKey, } impl Offer { @@ -359,7 +359,7 @@ impl Offer { /// The public key used by the recipient to sign invoices. pub fn signing_pubkey(&self) -> PublicKey { - self.contents.signing_pubkey.unwrap() + self.contents.signing_pubkey } /// Creates an [`InvoiceRequest`] for the offer with the given `metadata` and `payer_id`, which @@ -497,7 +497,7 @@ impl OfferContents { paths: self.paths.as_ref(), issuer: self.issuer.as_ref(), quantity_max: self.supported_quantity.to_tlv_record(), - node_id: self.signing_pubkey.as_ref(), + node_id: Some(&self.signing_pubkey), } } } @@ -634,13 +634,14 @@ impl TryFrom for OfferContents { Some(n) => Quantity::Bounded(NonZeroU64::new(n).unwrap()), }; - if node_id.is_none() { - return Err(SemanticError::MissingSigningPubkey); - } + let signing_pubkey = match node_id { + None => return Err(SemanticError::MissingSigningPubkey), + Some(node_id) => node_id, + }; Ok(OfferContents { chains, metadata, amount, description, features, absolute_expiry, issuer, paths, - supported_quantity, signing_pubkey: node_id, + supported_quantity, signing_pubkey, }) } } @@ -653,7 +654,7 @@ impl core::fmt::Display for Offer { #[cfg(test)] mod tests { - use super::{Amount, Offer, OfferBuilder, Quantity}; + use super::{Amount, Offer, OfferBuilder, OfferTlvStreamRef, Quantity}; use bitcoin::blockdata::constants::ChainHash; use bitcoin::network::constants::Network; @@ -680,7 +681,7 @@ mod tests { #[test] fn builds_offer_with_defaults() { let offer = OfferBuilder::new("foo".into(), pubkey(42)).build().unwrap(); - let tlv_stream = offer.as_tlv_stream(); + let mut buffer = Vec::new(); offer.write(&mut buffer).unwrap(); @@ -699,17 +700,22 @@ mod tests { assert_eq!(offer.supported_quantity(), Quantity::one()); assert_eq!(offer.signing_pubkey(), pubkey(42)); - assert_eq!(tlv_stream.chains, None); - assert_eq!(tlv_stream.metadata, None); - assert_eq!(tlv_stream.currency, None); - assert_eq!(tlv_stream.amount, None); - assert_eq!(tlv_stream.description, Some(&String::from("foo"))); - assert_eq!(tlv_stream.features, None); - assert_eq!(tlv_stream.absolute_expiry, None); - assert_eq!(tlv_stream.paths, None); - assert_eq!(tlv_stream.issuer, None); - assert_eq!(tlv_stream.quantity_max, None); - assert_eq!(tlv_stream.node_id, Some(&pubkey(42))); + assert_eq!( + offer.as_tlv_stream(), + OfferTlvStreamRef { + chains: None, + metadata: None, + currency: None, + amount: None, + description: Some(&String::from("foo")), + features: None, + absolute_expiry: None, + paths: None, + issuer: None, + quantity_max: None, + node_id: Some(&pubkey(42)), + }, + ); if let Err(e) = Offer::try_from(buffer) { panic!("error parsing offer: {:?}", e); @@ -1121,11 +1127,13 @@ mod tests { panic!("error parsing offer: {:?}", e); } - let mut builder = OfferBuilder::new("foo".into(), pubkey(42)); - builder.offer.signing_pubkey = None; + let mut tlv_stream = offer.as_tlv_stream(); + tlv_stream.node_id = None; - let offer = builder.build().unwrap(); - match offer.to_string().parse::() { + let mut encoded_offer = Vec::new(); + tlv_stream.write(&mut encoded_offer).unwrap(); + + match Offer::try_from(encoded_offer) { Ok(_) => panic!("expected error"), Err(e) => { assert_eq!(e, ParseError::InvalidSemantics(SemanticError::MissingSigningPubkey)); diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs index 0b3dda79285..b462e686910 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -127,20 +127,28 @@ pub enum SemanticError { AlreadyExpired, /// The provided chain hash does not correspond to a supported chain. UnsupportedChain, + /// A chain was provided but was not expected. + UnexpectedChain, /// An amount was expected but was missing. MissingAmount, /// The amount exceeded the total bitcoin supply. InvalidAmount, /// An amount was provided but was not sufficient in value. InsufficientAmount, + /// An amount was provided but was not expected. + UnexpectedAmount, /// A currency was provided that is not supported. UnsupportedCurrency, /// A feature was required but is unknown. UnknownRequiredFeatures, + /// Features were provided but were not expected. + UnexpectedFeatures, /// A required description was not provided. MissingDescription, /// A signing pubkey was not provided. MissingSigningPubkey, + /// A signing pubkey was provided but was not expected. + UnexpectedSigningPubkey, /// A quantity was expected but was missing. MissingQuantity, /// An unsupported quantity was provided. diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs new file mode 100644 index 00000000000..4e553cb3e6d --- /dev/null +++ b/lightning/src/offers/refund.rs @@ -0,0 +1,948 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Data structures and encoding for refunds. +//! +//! A [`Refund`] is an "offer for money" and is typically constructed by a merchant and presented +//! directly to the customer. The recipient responds with an `Invoice` to be paid. +//! +//! This is an [`InvoiceRequest`] produced *not* in response to an [`Offer`]. +//! +//! [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest +//! [`Offer`]: crate::offers::offer::Offer +//! +//! ```ignore +//! extern crate bitcoin; +//! extern crate core; +//! extern crate lightning; +//! +//! use core::convert::TryFrom; +//! use core::time::Duration; +//! +//! use bitcoin::network::constants::Network; +//! use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey}; +//! use lightning::offers::parse::ParseError; +//! use lightning::offers::refund::{Refund, RefundBuilder}; +//! use lightning::util::ser::{Readable, Writeable}; +//! +//! # use lightning::onion_message::BlindedPath; +//! # #[cfg(feature = "std")] +//! # use std::time::SystemTime; +//! # +//! # fn create_blinded_path() -> BlindedPath { unimplemented!() } +//! # fn create_another_blinded_path() -> BlindedPath { unimplemented!() } +//! # +//! # #[cfg(feature = "std")] +//! # fn build() -> Result<(), ParseError> { +//! let secp_ctx = Secp256k1::new(); +//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); +//! let pubkey = PublicKey::from(keys); +//! +//! let expiration = SystemTime::now() + Duration::from_secs(24 * 60 * 60); +//! let refund = RefundBuilder::new("coffee, large".to_string(), vec![1; 32], pubkey, 20_000)? +//! .absolute_expiry(expiration.duration_since(SystemTime::UNIX_EPOCH).unwrap()) +//! .issuer("Foo Bar".to_string()) +//! .path(create_blinded_path()) +//! .path(create_another_blinded_path()) +//! .chain(Network::Bitcoin) +//! .payer_note("refund for order #12345".to_string()) +//! .build()?; +//! +//! // Encode as a bech32 string for use in a QR code. +//! let encoded_refund = refund.to_string(); +//! +//! // Parse from a bech32 string after scanning from a QR code. +//! let refund = encoded_refund.parse::()?; +//! +//! // Encode refund as raw bytes. +//! let mut bytes = Vec::new(); +//! refund.write(&mut bytes).unwrap(); +//! +//! // Decode raw bytes into an refund. +//! let refund = Refund::try_from(bytes)?; +//! # Ok(()) +//! # } +//! ``` + +use bitcoin::blockdata::constants::ChainHash; +use bitcoin::network::constants::Network; +use bitcoin::secp256k1::PublicKey; +use core::convert::TryFrom; +use core::str::FromStr; +use core::time::Duration; +use crate::io; +use crate::ln::features::InvoiceRequestFeatures; +use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; +use crate::offers::invoice_request::{InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef}; +use crate::offers::offer::{OfferTlvStream, OfferTlvStreamRef}; +use crate::offers::parse::{Bech32Encode, ParseError, ParsedMessage, SemanticError}; +use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef}; +use crate::onion_message::BlindedPath; +use crate::util::ser::{SeekReadable, WithoutLength, Writeable, Writer}; +use crate::util::string::PrintableString; + +use crate::prelude::*; + +#[cfg(feature = "std")] +use std::time::SystemTime; + +/// Builds a [`Refund`] for the "offer for money" flow. +/// +/// See [module-level documentation] for usage. +/// +/// [module-level documentation]: self +pub struct RefundBuilder { + refund: RefundContents, +} + +impl RefundBuilder { + /// Creates a new builder for a refund using the [`Refund::payer_id`] for signing invoices. Use + /// a different pubkey per refund to avoid correlating refunds. + /// + /// Additionally, sets the required [`Refund::description`], [`Refund::metadata`], and + /// [`Refund::amount_msats`]. + pub fn new( + description: String, metadata: Vec, payer_id: PublicKey, amount_msats: u64 + ) -> Result { + if amount_msats > MAX_VALUE_MSAT { + return Err(SemanticError::InvalidAmount); + } + + let refund = RefundContents { + payer: PayerContents(metadata), metadata: None, description, absolute_expiry: None, + issuer: None, paths: None, chain: None, amount_msats, + features: InvoiceRequestFeatures::empty(), payer_id, payer_note: None, + }; + + Ok(RefundBuilder { refund }) + } + + /// Sets the [`Refund::absolute_expiry`] as seconds since the Unix epoch. Any expiry that has + /// already passed is valid and can be checked for using [`Refund::is_expired`]. + /// + /// Successive calls to this method will override the previous setting. + pub fn absolute_expiry(mut self, absolute_expiry: Duration) -> Self { + self.refund.absolute_expiry = Some(absolute_expiry); + self + } + + /// Sets the [`Refund::issuer`]. + /// + /// Successive calls to this method will override the previous setting. + pub fn issuer(mut self, issuer: String) -> Self { + self.refund.issuer = Some(issuer); + self + } + + /// Adds a blinded path to [`Refund::paths`]. Must include at least one path if only connected + /// by private channels or if [`Refund::payer_id`] is not a public node id. + /// + /// Successive calls to this method will add another blinded path. Caller is responsible for not + /// adding duplicate paths. + pub fn path(mut self, path: BlindedPath) -> Self { + self.refund.paths.get_or_insert_with(Vec::new).push(path); + self + } + + /// Sets the [`Refund::chain`] of the given [`Network`] for paying an invoice. If not + /// called, [`Network::Bitcoin`] is assumed. + /// + /// Successive calls to this method will override the previous setting. + pub fn chain(mut self, network: Network) -> Self { + self.refund.chain = Some(ChainHash::using_genesis_block(network)); + self + } + + /// Sets the [`Refund::payer_note`]. + /// + /// Successive calls to this method will override the previous setting. + pub fn payer_note(mut self, payer_note: String) -> Self { + self.refund.payer_note = Some(payer_note); + self + } + + /// Builds a [`Refund`] after checking for valid semantics. + pub fn build(mut self) -> Result { + if self.refund.chain() == self.refund.implied_chain() { + self.refund.chain = None; + } + + let mut bytes = Vec::new(); + self.refund.write(&mut bytes).unwrap(); + + Ok(Refund { + bytes, + contents: self.refund, + }) + } +} + +#[cfg(test)] +impl RefundBuilder { + fn features_unchecked(mut self, features: InvoiceRequestFeatures) -> Self { + self.refund.features = features; + self + } +} + +/// A `Refund` is a request to send an `Invoice` without a preceding [`Offer`]. +/// +/// Typically, after an invoice is paid, the recipient may publish a refund allowing the sender to +/// recoup their funds. A refund may be used more generally as an "offer for money", such as with a +/// bitcoin ATM. +/// +/// [`Offer`]: crate::offers::offer::Offer +#[derive(Clone, Debug)] +pub struct Refund { + bytes: Vec, + contents: RefundContents, +} + +/// The contents of a [`Refund`], which may be shared with an `Invoice`. +#[derive(Clone, Debug)] +struct RefundContents { + payer: PayerContents, + // offer fields + metadata: Option>, + description: String, + absolute_expiry: Option, + issuer: Option, + paths: Option>, + // invoice_request fields + chain: Option, + amount_msats: u64, + features: InvoiceRequestFeatures, + payer_id: PublicKey, + payer_note: Option, +} + +impl Refund { + /// A complete description of the purpose of the refund. Intended to be displayed to the user + /// but with the caveat that it has not been verified in any way. + pub fn description(&self) -> PrintableString { + PrintableString(&self.contents.description) + } + + /// Duration since the Unix epoch when an invoice should no longer be sent. + /// + /// If `None`, the refund does not expire. + pub fn absolute_expiry(&self) -> Option { + self.contents.absolute_expiry + } + + /// Whether the refund has expired. + #[cfg(feature = "std")] + pub fn is_expired(&self) -> bool { + match self.absolute_expiry() { + Some(seconds_from_epoch) => match SystemTime::UNIX_EPOCH.elapsed() { + Ok(elapsed) => elapsed > seconds_from_epoch, + Err(_) => false, + }, + None => false, + } + } + + /// The issuer of the refund, possibly beginning with `user@domain` or `domain`. Intended to be + /// displayed to the user but with the caveat that it has not been verified in any way. + pub fn issuer(&self) -> Option { + self.contents.issuer.as_ref().map(|issuer| PrintableString(issuer.as_str())) + } + + /// Paths to the sender originating from publicly reachable nodes. Blinded paths provide sender + /// privacy by obfuscating its node id. + pub fn paths(&self) -> &[BlindedPath] { + self.contents.paths.as_ref().map(|paths| paths.as_slice()).unwrap_or(&[]) + } + + /// An unpredictable series of bytes, typically containing information about the derivation of + /// [`payer_id`]. + /// + /// [`payer_id`]: Self::payer_id + pub fn metadata(&self) -> &[u8] { + &self.contents.payer.0 + } + + /// A chain that the refund is valid for. + pub fn chain(&self) -> ChainHash { + self.contents.chain.unwrap_or_else(|| self.contents.implied_chain()) + } + + /// The amount to refund in msats (i.e., the minimum lightning-payable unit for [`chain`]). + /// + /// [`chain`]: Self::chain + pub fn amount_msats(&self) -> u64 { + self.contents.amount_msats + } + + /// Features pertaining to requesting an invoice. + pub fn features(&self) -> &InvoiceRequestFeatures { + &self.contents.features + } + + /// A possibly transient pubkey used to sign the refund. + pub fn payer_id(&self) -> PublicKey { + self.contents.payer_id + } + + /// Payer provided note to include in the invoice. + pub fn payer_note(&self) -> Option { + self.contents.payer_note.as_ref().map(|payer_note| PrintableString(payer_note.as_str())) + } + + #[cfg(test)] + fn as_tlv_stream(&self) -> RefundTlvStreamRef { + self.contents.as_tlv_stream() + } +} + +impl AsRef<[u8]> for Refund { + fn as_ref(&self) -> &[u8] { + &self.bytes + } +} + +impl RefundContents { + fn chain(&self) -> ChainHash { + self.chain.unwrap_or_else(|| self.implied_chain()) + } + + pub fn implied_chain(&self) -> ChainHash { + ChainHash::using_genesis_block(Network::Bitcoin) + } + + pub(super) fn as_tlv_stream(&self) -> RefundTlvStreamRef { + let payer = PayerTlvStreamRef { + metadata: Some(&self.payer.0), + }; + + let offer = OfferTlvStreamRef { + chains: None, + metadata: self.metadata.as_ref(), + currency: None, + amount: None, + description: Some(&self.description), + features: None, + absolute_expiry: self.absolute_expiry.map(|duration| duration.as_secs()), + paths: self.paths.as_ref(), + issuer: self.issuer.as_ref(), + quantity_max: None, + node_id: None, + }; + + let features = { + if self.features == InvoiceRequestFeatures::empty() { None } + else { Some(&self.features) } + }; + + let invoice_request = InvoiceRequestTlvStreamRef { + chain: self.chain.as_ref(), + amount: Some(self.amount_msats), + features, + quantity: None, + payer_id: Some(&self.payer_id), + payer_note: self.payer_note.as_ref(), + }; + + (payer, offer, invoice_request) + } +} + +impl Writeable for Refund { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + WithoutLength(&self.bytes).write(writer) + } +} + +impl Writeable for RefundContents { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + self.as_tlv_stream().write(writer) + } +} + +type RefundTlvStream = (PayerTlvStream, OfferTlvStream, InvoiceRequestTlvStream); + +type RefundTlvStreamRef<'a> = ( + PayerTlvStreamRef<'a>, + OfferTlvStreamRef<'a>, + InvoiceRequestTlvStreamRef<'a>, +); + +impl SeekReadable for RefundTlvStream { + fn read(r: &mut R) -> Result { + let payer = SeekReadable::read(r)?; + let offer = SeekReadable::read(r)?; + let invoice_request = SeekReadable::read(r)?; + + Ok((payer, offer, invoice_request)) + } +} + +impl Bech32Encode for Refund { + const BECH32_HRP: &'static str = "lnr"; +} + +impl FromStr for Refund { + type Err = ParseError; + + fn from_str(s: &str) -> Result::Err> { + Refund::from_bech32_str(s) + } +} + +impl TryFrom> for Refund { + type Error = ParseError; + + fn try_from(bytes: Vec) -> Result { + let refund = ParsedMessage::::try_from(bytes)?; + let ParsedMessage { bytes, tlv_stream } = refund; + let contents = RefundContents::try_from(tlv_stream)?; + + Ok(Refund { bytes, contents }) + } +} + +impl TryFrom for RefundContents { + type Error = SemanticError; + + fn try_from(tlv_stream: RefundTlvStream) -> Result { + let ( + PayerTlvStream { metadata: payer_metadata }, + OfferTlvStream { + chains, metadata, currency, amount: offer_amount, description, + features: offer_features, absolute_expiry, paths, issuer, quantity_max, node_id, + }, + InvoiceRequestTlvStream { chain, amount, features, quantity, payer_id, payer_note }, + ) = tlv_stream; + + let payer = match payer_metadata { + None => return Err(SemanticError::MissingPayerMetadata), + Some(metadata) => PayerContents(metadata), + }; + + if chains.is_some() { + return Err(SemanticError::UnexpectedChain); + } + + if currency.is_some() || offer_amount.is_some() { + return Err(SemanticError::UnexpectedAmount); + } + + let description = match description { + None => return Err(SemanticError::MissingDescription), + Some(description) => description, + }; + + if offer_features.is_some() { + return Err(SemanticError::UnexpectedFeatures); + } + + let absolute_expiry = absolute_expiry.map(Duration::from_secs); + + if quantity_max.is_some() { + return Err(SemanticError::UnexpectedQuantity); + } + + if node_id.is_some() { + return Err(SemanticError::UnexpectedSigningPubkey); + } + + let amount_msats = match amount { + None => return Err(SemanticError::MissingAmount), + Some(amount_msats) if amount_msats > MAX_VALUE_MSAT => { + return Err(SemanticError::InvalidAmount); + }, + Some(amount_msats) => amount_msats, + }; + + let features = features.unwrap_or_else(InvoiceRequestFeatures::empty); + + // TODO: Check why this isn't in the spec. + if quantity.is_some() { + return Err(SemanticError::UnexpectedQuantity); + } + + let payer_id = match payer_id { + None => return Err(SemanticError::MissingPayerId), + Some(payer_id) => payer_id, + }; + + // TODO: Should metadata be included? + Ok(RefundContents { + payer, metadata, description, absolute_expiry, issuer, paths, chain, amount_msats, + features, payer_id, payer_note, + }) + } +} + +impl core::fmt::Display for Refund { + fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { + self.fmt_bech32_str(f) + } +} + +#[cfg(test)] +mod tests { + use super::{Refund, RefundBuilder, RefundTlvStreamRef}; + + use bitcoin::blockdata::constants::ChainHash; + use bitcoin::network::constants::Network; + use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey}; + use core::convert::TryFrom; + use core::time::Duration; + use crate::ln::features::{InvoiceRequestFeatures, OfferFeatures}; + use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; + use crate::offers::invoice_request::InvoiceRequestTlvStreamRef; + use crate::offers::offer::OfferTlvStreamRef; + use crate::offers::parse::{ParseError, SemanticError}; + use crate::offers::payer::PayerTlvStreamRef; + use crate::onion_message::{BlindedHop, BlindedPath}; + use crate::util::ser::{BigSize, Writeable}; + use crate::util::string::PrintableString; + + fn payer_pubkey() -> PublicKey { + let secp_ctx = Secp256k1::new(); + KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()).public_key() + } + + fn pubkey(byte: u8) -> PublicKey { + let secp_ctx = Secp256k1::new(); + PublicKey::from_secret_key(&secp_ctx, &privkey(byte)) + } + + fn privkey(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).unwrap() + } + + trait ToBytes { + fn to_bytes(&self) -> Vec; + } + + impl<'a> ToBytes for RefundTlvStreamRef<'a> { + fn to_bytes(&self) -> Vec { + let mut buffer = Vec::new(); + self.write(&mut buffer).unwrap(); + buffer + } + } + + #[test] + fn builds_refund_with_defaults() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .build().unwrap(); + + let mut buffer = Vec::new(); + refund.write(&mut buffer).unwrap(); + + assert_eq!(refund.bytes, buffer.as_slice()); + assert_eq!(refund.metadata(), &[1; 32]); + assert_eq!(refund.description(), PrintableString("foo")); + assert_eq!(refund.absolute_expiry(), None); + #[cfg(feature = "std")] + assert!(!refund.is_expired()); + assert_eq!(refund.paths(), &[]); + assert_eq!(refund.issuer(), None); + assert_eq!(refund.chain(), ChainHash::using_genesis_block(Network::Bitcoin)); + assert_eq!(refund.amount_msats(), 1000); + assert_eq!(refund.features(), &InvoiceRequestFeatures::empty()); + assert_eq!(refund.payer_id(), payer_pubkey()); + assert_eq!(refund.payer_note(), None); + + assert_eq!( + refund.as_tlv_stream(), + ( + PayerTlvStreamRef { metadata: Some(&vec![1; 32]) }, + OfferTlvStreamRef { + chains: None, + metadata: None, + currency: None, + amount: None, + description: Some(&String::from("foo")), + features: None, + absolute_expiry: None, + paths: None, + issuer: None, + quantity_max: None, + node_id: None, + }, + InvoiceRequestTlvStreamRef { + chain: None, + amount: Some(1000), + features: None, + quantity: None, + payer_id: Some(&payer_pubkey()), + payer_note: None, + }, + ), + ); + + if let Err(e) = Refund::try_from(buffer) { + panic!("error parsing refund: {:?}", e); + } + } + + #[test] + fn fails_building_refund_with_invalid_amount() { + match RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), MAX_VALUE_MSAT + 1) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, SemanticError::InvalidAmount), + } + } + + #[test] + fn builds_refund_with_absolute_expiry() { + let future_expiry = Duration::from_secs(u64::max_value()); + let past_expiry = Duration::from_secs(0); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .absolute_expiry(future_expiry) + .build() + .unwrap(); + let (_, tlv_stream, _) = refund.as_tlv_stream(); + #[cfg(feature = "std")] + assert!(!refund.is_expired()); + assert_eq!(refund.absolute_expiry(), Some(future_expiry)); + assert_eq!(tlv_stream.absolute_expiry, Some(future_expiry.as_secs())); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .absolute_expiry(future_expiry) + .absolute_expiry(past_expiry) + .build() + .unwrap(); + let (_, tlv_stream, _) = refund.as_tlv_stream(); + #[cfg(feature = "std")] + assert!(refund.is_expired()); + assert_eq!(refund.absolute_expiry(), Some(past_expiry)); + assert_eq!(tlv_stream.absolute_expiry, Some(past_expiry.as_secs())); + } + + #[test] + fn builds_refund_with_paths() { + let paths = vec![ + BlindedPath { + introduction_node_id: pubkey(40), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] }, + ], + }, + BlindedPath { + introduction_node_id: pubkey(40), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(45), encrypted_payload: vec![0; 45] }, + BlindedHop { blinded_node_id: pubkey(46), encrypted_payload: vec![0; 46] }, + ], + }, + ]; + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .path(paths[0].clone()) + .path(paths[1].clone()) + .build() + .unwrap(); + let (_, offer_tlv_stream, invoice_request_tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.paths(), paths.as_slice()); + assert_eq!(refund.payer_id(), pubkey(42)); + assert_ne!(pubkey(42), pubkey(44)); + assert_eq!(offer_tlv_stream.paths, Some(&paths)); + assert_eq!(invoice_request_tlv_stream.payer_id, Some(&pubkey(42))); + } + + #[test] + fn builds_refund_with_issuer() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .issuer("bar".into()) + .build() + .unwrap(); + let (_, tlv_stream, _) = refund.as_tlv_stream(); + assert_eq!(refund.issuer(), Some(PrintableString("bar"))); + assert_eq!(tlv_stream.issuer, Some(&String::from("bar"))); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .issuer("bar".into()) + .issuer("baz".into()) + .build() + .unwrap(); + let (_, tlv_stream, _) = refund.as_tlv_stream(); + assert_eq!(refund.issuer(), Some(PrintableString("baz"))); + assert_eq!(tlv_stream.issuer, Some(&String::from("baz"))); + } + + #[test] + fn builds_refund_with_chain() { + let mainnet = ChainHash::using_genesis_block(Network::Bitcoin); + let testnet = ChainHash::using_genesis_block(Network::Testnet); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .chain(Network::Bitcoin) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.chain(), mainnet); + assert_eq!(tlv_stream.chain, None); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .chain(Network::Testnet) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.chain(), testnet); + assert_eq!(tlv_stream.chain, Some(&testnet)); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .chain(Network::Regtest) + .chain(Network::Testnet) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.chain(), testnet); + assert_eq!(tlv_stream.chain, Some(&testnet)); + } + + #[test] + fn builds_refund_with_payer_note() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .payer_note("bar".into()) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.payer_note(), Some(PrintableString("bar"))); + assert_eq!(tlv_stream.payer_note, Some(&String::from("bar"))); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .payer_note("bar".into()) + .payer_note("baz".into()) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.payer_note(), Some(PrintableString("baz"))); + assert_eq!(tlv_stream.payer_note, Some(&String::from("baz"))); + } + + #[test] + fn parses_refund_with_metadata() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.0.metadata = None; + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::MissingPayerMetadata)); + }, + } + } + + #[test] + fn parses_refund_with_description() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.description = None; + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::MissingDescription)); + }, + } + } + + #[test] + fn parses_refund_with_amount() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.2.amount = None; + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::MissingAmount)); + }, + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.2.amount = Some(MAX_VALUE_MSAT + 1); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::InvalidAmount)); + }, + } + } + + #[test] + fn parses_refund_with_payer_id() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.2.payer_id = None; + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::MissingPayerId)); + }, + } + } + + #[test] + fn parses_refund_with_optional_fields() { + let past_expiry = Duration::from_secs(0); + let paths = vec![ + BlindedPath { + introduction_node_id: pubkey(40), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] }, + ], + }, + BlindedPath { + introduction_node_id: pubkey(40), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(45), encrypted_payload: vec![0; 45] }, + BlindedHop { blinded_node_id: pubkey(46), encrypted_payload: vec![0; 46] }, + ], + }, + ]; + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .absolute_expiry(past_expiry) + .issuer("bar".into()) + .path(paths[0].clone()) + .path(paths[1].clone()) + .chain(Network::Testnet) + .features_unchecked(InvoiceRequestFeatures::unknown()) + .payer_note("baz".into()) + .build() + .unwrap(); + match refund.to_string().parse::() { + Ok(refund) => { + assert_eq!(refund.absolute_expiry(), Some(past_expiry)); + #[cfg(feature = "std")] + assert!(refund.is_expired()); + assert_eq!(refund.paths(), &paths[..]); + assert_eq!(refund.issuer(), Some(PrintableString("bar"))); + assert_eq!(refund.chain(), ChainHash::using_genesis_block(Network::Testnet)); + assert_eq!(refund.features(), &InvoiceRequestFeatures::unknown()); + assert_eq!(refund.payer_note(), Some(PrintableString("baz"))); + }, + Err(e) => panic!("error parsing refund: {:?}", e), + } + } + + #[test] + fn fails_parsing_refund_with_unexpected_fields() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .build().unwrap(); + if let Err(e) = refund.to_string().parse::() { + panic!("error parsing refund: {:?}", e); + } + + let chains = vec![ChainHash::using_genesis_block(Network::Testnet)]; + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.chains = Some(&chains); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedChain)); + }, + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.currency = Some(&b"USD"); + tlv_stream.1.amount = Some(1000); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedAmount)); + }, + } + + let features = OfferFeatures::unknown(); + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.features = Some(&features); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedFeatures)); + }, + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.quantity_max = Some(10); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedQuantity)); + }, + } + + let node_id = payer_pubkey(); + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.node_id = Some(&node_id); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedSigningPubkey)); + }, + } + + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.2.quantity = Some(10); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedQuantity)); + }, + } + } + + #[test] + fn fails_parsing_refund_with_extra_tlv_records() { + let secp_ctx = Secp256k1::new(); + let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); + let refund = RefundBuilder::new("foo".into(), vec![1; 32], keys.public_key(), 1000).unwrap() + .build().unwrap(); + + let mut encoded_refund = Vec::new(); + refund.write(&mut encoded_refund).unwrap(); + BigSize(1002).write(&mut encoded_refund).unwrap(); + BigSize(32).write(&mut encoded_refund).unwrap(); + [42u8; 32].write(&mut encoded_refund).unwrap(); + + match Refund::try_from(encoded_refund) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)), + } + } +} diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index 231320ac159..df504cc2af6 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -510,6 +510,7 @@ macro_rules! tlv_stream { )* } + #[derive(Debug, PartialEq)] pub(super) struct $nameref<'a> { $( pub(super) $field: Option,