X-Git-Url: http://git.bitcoin.ninja/index.cgi?a=blobdiff_plain;f=lightning%2Fsrc%2Foffers%2Frefund.rs;h=4628c334fbefa54d27386a127732c624e2976643;hb=022eadc4dbf0f60179674f936d604cade6c5dd9e;hp=4e553cb3e6d47b0aac3aa374c47c238728c67528;hpb=e38ab09c3a9514768a9833b2636b2b969f62b3e1;p=rust-lightning diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index 4e553cb3..4628c334 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -10,14 +10,15 @@ //! 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. +//! directly to the customer. The recipient responds with an [`Invoice`] to be paid. //! //! This is an [`InvoiceRequest`] produced *not* in response to an [`Offer`]. //! +//! [`Invoice`]: crate::offers::invoice::Invoice //! [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest //! [`Offer`]: crate::offers::offer::Offer //! -//! ```ignore +//! ``` //! extern crate bitcoin; //! extern crate core; //! extern crate lightning; @@ -77,12 +78,15 @@ use core::convert::TryFrom; use core::str::FromStr; use core::time::Duration; use crate::io; +use crate::ln::PaymentHash; use crate::ln::features::InvoiceRequestFeatures; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; +use crate::offers::invoice::{BlindedPayInfo, InvoiceBuilder}; 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::offers::signer::Metadata; use crate::onion_message::BlindedPath; use crate::util::ser::{SeekReadable, WithoutLength, Writeable, Writer}; use crate::util::string::PrintableString; @@ -102,8 +106,8 @@ pub struct RefundBuilder { } 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. + /// Creates a new builder for a refund using the [`Refund::payer_id`] for the public node id to + /// send to if no [`Refund::paths`] are set. Otherwise, it may be a transient pubkey. /// /// Additionally, sets the required [`Refund::description`], [`Refund::metadata`], and /// [`Refund::amount_msats`]. @@ -114,10 +118,11 @@ impl RefundBuilder { return Err(SemanticError::InvalidAmount); } + let metadata = Metadata::Bytes(metadata); 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, + payer: PayerContents(metadata), description, absolute_expiry: None, issuer: None, + paths: None, chain: None, amount_msats, features: InvoiceRequestFeatures::empty(), + quantity: None, payer_id, payer_note: None, }; Ok(RefundBuilder { refund }) @@ -159,6 +164,20 @@ impl RefundBuilder { self } + /// Sets [`Refund::quantity`] of items. This is purely for informational purposes. It is useful + /// when the refund pertains to an [`Invoice`] that paid for more than one item from an + /// [`Offer`] as specified by [`InvoiceRequest::quantity`]. + /// + /// Successive calls to this method will override the previous setting. + /// + /// [`Invoice`]: crate::offers::invoice::Invoice + /// [`InvoiceRequest::quantity`]: crate::offers::invoice_request::InvoiceRequest::quantity + /// [`Offer`]: crate::offers::offer::Offer + pub fn quantity(mut self, quantity: u64) -> Self { + self.refund.quantity = Some(quantity); + self + } + /// Sets the [`Refund::payer_note`]. /// /// Successive calls to this method will override the previous setting. @@ -191,25 +210,29 @@ impl RefundBuilder { } } -/// A `Refund` is a request to send an `Invoice` without a preceding [`Offer`]. +/// 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. /// +/// [`Invoice`]: crate::offers::invoice::Invoice /// [`Offer`]: crate::offers::offer::Offer #[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub struct Refund { - bytes: Vec, - contents: RefundContents, + pub(super) bytes: Vec, + pub(super) contents: RefundContents, } -/// The contents of a [`Refund`], which may be shared with an `Invoice`. +/// The contents of a [`Refund`], which may be shared with an [`Invoice`]. +/// +/// [`Invoice`]: crate::offers::invoice::Invoice #[derive(Clone, Debug)] -struct RefundContents { +#[cfg_attr(test, derive(PartialEq))] +pub(super) struct RefundContents { payer: PayerContents, // offer fields - metadata: Option>, description: String, absolute_expiry: Option, issuer: Option, @@ -218,6 +241,7 @@ struct RefundContents { chain: Option, amount_msats: u64, features: InvoiceRequestFeatures, + quantity: Option, payer_id: PublicKey, payer_note: Option, } @@ -239,13 +263,7 @@ impl Refund { /// 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, - } + self.contents.is_expired() } /// The issuer of the refund, possibly beginning with `user@domain` or `domain`. Intended to be @@ -265,7 +283,7 @@ impl Refund { /// /// [`payer_id`]: Self::payer_id pub fn metadata(&self) -> &[u8] { - &self.contents.payer.0 + &self.contents.payer.0.as_bytes().unwrap()[..] } /// A chain that the refund is valid for. @@ -285,7 +303,15 @@ impl Refund { &self.contents.features } - /// A possibly transient pubkey used to sign the refund. + /// The quantity of an item that refund is for. + pub fn quantity(&self) -> Option { + self.contents.quantity + } + + /// A public node id to send to in the case where there are no [`paths`]. Otherwise, a possibly + /// transient pubkey. + /// + /// [`paths`]: Self::paths pub fn payer_id(&self) -> PublicKey { self.contents.payer_id } @@ -295,6 +321,56 @@ impl Refund { self.contents.payer_note.as_ref().map(|payer_note| PrintableString(payer_note.as_str())) } + /// Creates an [`InvoiceBuilder`] for the refund with the given required fields and using the + /// [`Duration`] since [`std::time::SystemTime::UNIX_EPOCH`] as the creation time. + /// + /// See [`Refund::respond_with_no_std`] for further details where the aforementioned creation + /// time is used for the `created_at` parameter. + /// + /// [`Duration`]: core::time::Duration + #[cfg(feature = "std")] + pub fn respond_with( + &self, payment_paths: Vec<(BlindedPath, BlindedPayInfo)>, payment_hash: PaymentHash, + signing_pubkey: PublicKey, + ) -> Result { + let created_at = std::time::SystemTime::now() + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); + + self.respond_with_no_std(payment_paths, payment_hash, signing_pubkey, created_at) + } + + /// Creates an [`InvoiceBuilder`] for the refund with the given required fields. + /// + /// Unless [`InvoiceBuilder::relative_expiry`] is set, the invoice will expire two hours after + /// `created_at`, which is used to set [`Invoice::created_at`]. Useful for `no-std` builds where + /// [`std::time::SystemTime`] is not available. + /// + /// The caller is expected to remember the preimage of `payment_hash` in order to + /// claim a payment for the invoice. + /// + /// The `signing_pubkey` is required to sign the invoice since refunds are not in response to an + /// offer, which does have a `signing_pubkey`. + /// + /// The `payment_paths` parameter is useful for maintaining the payment recipient's privacy. It + /// must contain one or more elements ordered from most-preferred to least-preferred, if there's + /// a preference. Note, however, that any privacy is lost if a public node id is used for + /// `signing_pubkey`. + /// + /// Errors if the request contains unknown required features. + /// + /// [`Invoice::created_at`]: crate::offers::invoice::Invoice::created_at + pub fn respond_with_no_std( + &self, payment_paths: Vec<(BlindedPath, BlindedPayInfo)>, payment_hash: PaymentHash, + signing_pubkey: PublicKey, created_at: Duration + ) -> Result { + if self.features().requires_unknown_bits() { + return Err(SemanticError::UnknownRequiredFeatures); + } + + InvoiceBuilder::for_refund(self, payment_paths, created_at, payment_hash, signing_pubkey) + } + #[cfg(test)] fn as_tlv_stream(&self) -> RefundTlvStreamRef { self.contents.as_tlv_stream() @@ -308,7 +384,18 @@ impl AsRef<[u8]> for Refund { } impl RefundContents { - fn chain(&self) -> ChainHash { + #[cfg(feature = "std")] + pub(super) 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, + } + } + + pub(super) fn chain(&self) -> ChainHash { self.chain.unwrap_or_else(|| self.implied_chain()) } @@ -318,12 +405,12 @@ impl RefundContents { pub(super) fn as_tlv_stream(&self) -> RefundTlvStreamRef { let payer = PayerTlvStreamRef { - metadata: Some(&self.payer.0), + metadata: self.payer.0.as_bytes(), }; let offer = OfferTlvStreamRef { chains: None, - metadata: self.metadata.as_ref(), + metadata: None, currency: None, amount: None, description: Some(&self.description), @@ -344,7 +431,7 @@ impl RefundContents { chain: self.chain.as_ref(), amount: Some(self.amount_msats), features, - quantity: None, + quantity: self.quantity, payer_id: Some(&self.payer_id), payer_note: self.payer_note.as_ref(), }; @@ -422,9 +509,13 @@ impl TryFrom for RefundContents { let payer = match payer_metadata { None => return Err(SemanticError::MissingPayerMetadata), - Some(metadata) => PayerContents(metadata), + Some(metadata) => PayerContents(Metadata::Bytes(metadata)), }; + if metadata.is_some() { + return Err(SemanticError::UnexpectedMetadata); + } + if chains.is_some() { return Err(SemanticError::UnexpectedChain); } @@ -462,20 +553,14 @@ impl TryFrom for RefundContents { 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, + payer, description, absolute_expiry, issuer, paths, chain, amount_msats, features, + quantity, payer_id, payer_note, }) } } @@ -492,7 +577,7 @@ mod tests { use bitcoin::blockdata::constants::ChainHash; use bitcoin::network::constants::Network; - use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey}; + use bitcoin::secp256k1::{KeyPair, Secp256k1, SecretKey}; use core::convert::TryFrom; use core::time::Duration; use crate::ln::features::{InvoiceRequestFeatures, OfferFeatures}; @@ -501,24 +586,11 @@ mod tests { use crate::offers::offer::OfferTlvStreamRef; use crate::offers::parse::{ParseError, SemanticError}; use crate::offers::payer::PayerTlvStreamRef; + use crate::offers::test_utils::*; 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; } @@ -703,6 +775,24 @@ mod tests { assert_eq!(tlv_stream.chain, Some(&testnet)); } + #[test] + fn builds_refund_with_quantity() { + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .quantity(10) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.quantity(), Some(10)); + assert_eq!(tlv_stream.quantity, Some(10)); + + let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .quantity(10) + .quantity(1) + .build().unwrap(); + let (_, _, tlv_stream) = refund.as_tlv_stream(); + assert_eq!(refund.quantity(), Some(1)); + assert_eq!(tlv_stream.quantity, Some(1)); + } + #[test] fn builds_refund_with_payer_note() { let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() @@ -721,6 +811,18 @@ mod tests { assert_eq!(tlv_stream.payer_note, Some(&String::from("baz"))); } + #[test] + fn fails_responding_with_unknown_required_features() { + match RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() + .features_unchecked(InvoiceRequestFeatures::unknown()) + .build().unwrap() + .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, SemanticError::UnknownRequiredFeatures), + } + } + #[test] fn parses_refund_with_metadata() { let refund = RefundBuilder::new("foo".into(), vec![1; 32], payer_pubkey(), 1000).unwrap() @@ -836,6 +938,7 @@ mod tests { .path(paths[1].clone()) .chain(Network::Testnet) .features_unchecked(InvoiceRequestFeatures::unknown()) + .quantity(10) .payer_note("baz".into()) .build() .unwrap(); @@ -848,6 +951,7 @@ mod tests { 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.quantity(), Some(10)); assert_eq!(refund.payer_note(), Some(PrintableString("baz"))); }, Err(e) => panic!("error parsing refund: {:?}", e), @@ -862,6 +966,17 @@ mod tests { panic!("error parsing refund: {:?}", e); } + let metadata = vec![42; 32]; + let mut tlv_stream = refund.as_tlv_stream(); + tlv_stream.1.metadata = Some(&metadata); + + match Refund::try_from(tlv_stream.to_bytes()) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, ParseError::InvalidSemantics(SemanticError::UnexpectedMetadata)); + }, + } + let chains = vec![ChainHash::using_genesis_block(Network::Testnet)]; let mut tlv_stream = refund.as_tlv_stream(); tlv_stream.1.chains = Some(&chains); @@ -915,16 +1030,6 @@ mod tests { 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]