mirror of
https://github.com/lightningdevkit/rust-lightning.git
synced 2025-02-24 23:08:36 +01:00
Builder for creating invoice requests
Add a builder for creating invoice requests for an offer given a payer_id. Other settings may be optional depending on the offer and duplicative settings will override previous settings. Building produces a semantically valid `invoice_request` message for the offer, which then can be signed for the payer_id.
This commit is contained in:
parent
59a7bd29fe
commit
13ba7cc523
4 changed files with 407 additions and 65 deletions
|
@ -8,23 +8,208 @@
|
|||
// licenses.
|
||||
|
||||
//! 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
|
||||
//! 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.
|
||||
//!
|
||||
//! The recipient of the request responds with an `Invoice`.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! extern crate bitcoin;
|
||||
//! extern crate lightning;
|
||||
//!
|
||||
//! use bitcoin::network::constants::Network;
|
||||
//! use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey};
|
||||
//! use core::convert::Infallible;
|
||||
//! use lightning::ln::features::OfferFeatures;
|
||||
//! use lightning::offers::offer::Offer;
|
||||
//! use lightning::util::ser::Writeable;
|
||||
//!
|
||||
//! # fn parse() -> Result<(), lightning::offers::parse::ParseError> {
|
||||
//! let secp_ctx = Secp256k1::new();
|
||||
//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32])?);
|
||||
//! let pubkey = PublicKey::from(keys);
|
||||
//! let mut buffer = Vec::new();
|
||||
//!
|
||||
//! // "offer to be paid" flow
|
||||
//! "lno1qcp4256ypq"
|
||||
//! .parse::<Offer>()?
|
||||
//! .request_invoice(vec![42; 64], pubkey)?
|
||||
//! .chain(Network::Testnet)?
|
||||
//! .amount_msats(1000)?
|
||||
//! .quantity(5)?
|
||||
//! .payer_note("foo".to_string())
|
||||
//! .build()?
|
||||
//! .sign::<_, Infallible>(|digest| Ok(secp_ctx.sign_schnorr_no_aux_rand(digest, &keys)))
|
||||
//! .expect("failed verifying signature")
|
||||
//! .write(&mut buffer)
|
||||
//! .unwrap();
|
||||
//! # Ok(())
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
use bitcoin::blockdata::constants::ChainHash;
|
||||
use bitcoin::secp256k1::PublicKey;
|
||||
use bitcoin::network::constants::Network;
|
||||
use bitcoin::secp256k1::{Message, PublicKey};
|
||||
use bitcoin::secp256k1::schnorr::Signature;
|
||||
use core::convert::TryFrom;
|
||||
use crate::io;
|
||||
use crate::ln::features::InvoiceRequestFeatures;
|
||||
use crate::ln::msgs::DecodeError;
|
||||
use crate::offers::merkle::{SignatureTlvStream, self};
|
||||
use crate::offers::offer::{Amount, OfferContents, OfferTlvStream};
|
||||
use crate::offers::merkle::{SignError, SignatureTlvStream, SignatureTlvStreamRef, self};
|
||||
use crate::offers::offer::{Offer, OfferContents, OfferTlvStream, OfferTlvStreamRef};
|
||||
use crate::offers::parse::{ParseError, ParsedMessage, SemanticError};
|
||||
use crate::offers::payer::{PayerContents, PayerTlvStream};
|
||||
use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef};
|
||||
use crate::util::ser::{HighZeroBytesDroppedBigSize, SeekReadable, WithoutLength, Writeable, Writer};
|
||||
use crate::util::string::PrintableString;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
const SIGNATURE_TAG: &'static str = concat!("lightning", "invoice_request", "signature");
|
||||
|
||||
/// Builds an [`InvoiceRequest`] from an [`Offer`] for the "offer to be paid" flow.
|
||||
///
|
||||
/// See [module-level documentation] for usage.
|
||||
///
|
||||
/// [module-level documentation]: self
|
||||
pub struct InvoiceRequestBuilder<'a> {
|
||||
offer: &'a Offer,
|
||||
invoice_request: InvoiceRequestContents,
|
||||
}
|
||||
|
||||
impl<'a> InvoiceRequestBuilder<'a> {
|
||||
pub(super) fn new(offer: &'a Offer, metadata: Vec<u8>, payer_id: PublicKey) -> Self {
|
||||
Self {
|
||||
offer,
|
||||
invoice_request: InvoiceRequestContents {
|
||||
payer: PayerContents(metadata), offer: offer.contents.clone(), chain: None,
|
||||
amount_msats: None, features: InvoiceRequestFeatures::empty(), quantity: None,
|
||||
payer_id, payer_note: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the [`InvoiceRequest::chain`] of the given [`Network`] for paying an invoice. If not
|
||||
/// called, [`Network::Bitcoin`] is assumed. Errors if the chain for `network` is not supported
|
||||
/// by the offer.
|
||||
///
|
||||
/// Successive calls to this method will override the previous setting.
|
||||
pub fn chain(mut self, network: Network) -> Result<Self, SemanticError> {
|
||||
let chain = ChainHash::using_genesis_block(network);
|
||||
if !self.offer.supports_chain(chain) {
|
||||
return Err(SemanticError::UnsupportedChain);
|
||||
}
|
||||
|
||||
self.invoice_request.chain = Some(chain);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Sets the [`InvoiceRequest::amount_msats`] for paying an invoice. Errors if `amount_msats` is
|
||||
/// not at least the expected invoice amount (i.e., [`Offer::amount`] times [`quantity`]).
|
||||
///
|
||||
/// Successive calls to this method will override the previous setting.
|
||||
///
|
||||
/// [`quantity`]: Self::quantity
|
||||
pub fn amount_msats(mut self, amount_msats: u64) -> Result<Self, SemanticError> {
|
||||
self.invoice_request.offer.check_amount_msats_for_quantity(
|
||||
Some(amount_msats), self.invoice_request.quantity
|
||||
)?;
|
||||
self.invoice_request.amount_msats = Some(amount_msats);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Sets [`InvoiceRequest::quantity`] of items. If not set, `1` is assumed. Errors if `quantity`
|
||||
/// does not conform to [`Offer::is_valid_quantity`].
|
||||
///
|
||||
/// Successive calls to this method will override the previous setting.
|
||||
pub fn quantity(mut self, quantity: u64) -> Result<Self, SemanticError> {
|
||||
self.invoice_request.offer.check_quantity(Some(quantity))?;
|
||||
self.invoice_request.quantity = Some(quantity);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Sets the [`InvoiceRequest::payer_note`].
|
||||
///
|
||||
/// Successive calls to this method will override the previous setting.
|
||||
pub fn payer_note(mut self, payer_note: String) -> Self {
|
||||
self.invoice_request.payer_note = Some(payer_note);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds an unsigned [`InvoiceRequest`] after checking for valid semantics. It can be signed
|
||||
/// by [`UnsignedInvoiceRequest::sign`].
|
||||
pub fn build(mut self) -> Result<UnsignedInvoiceRequest<'a>, SemanticError> {
|
||||
#[cfg(feature = "std")] {
|
||||
if self.offer.is_expired() {
|
||||
return Err(SemanticError::AlreadyExpired);
|
||||
}
|
||||
}
|
||||
|
||||
let chain = self.invoice_request.chain();
|
||||
if !self.offer.supports_chain(chain) {
|
||||
return Err(SemanticError::UnsupportedChain);
|
||||
}
|
||||
|
||||
if chain == self.offer.implied_chain() {
|
||||
self.invoice_request.chain = None;
|
||||
}
|
||||
|
||||
if self.offer.amount().is_none() && self.invoice_request.amount_msats.is_none() {
|
||||
return Err(SemanticError::MissingAmount);
|
||||
}
|
||||
|
||||
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
|
||||
)?;
|
||||
|
||||
let InvoiceRequestBuilder { offer, invoice_request } = self;
|
||||
Ok(UnsignedInvoiceRequest { offer, invoice_request })
|
||||
}
|
||||
}
|
||||
|
||||
/// A semantically valid [`InvoiceRequest`] that hasn't been signed.
|
||||
pub struct UnsignedInvoiceRequest<'a> {
|
||||
offer: &'a Offer,
|
||||
invoice_request: InvoiceRequestContents,
|
||||
}
|
||||
|
||||
impl<'a> UnsignedInvoiceRequest<'a> {
|
||||
/// Signs the invoice request using the given function.
|
||||
pub fn sign<F, E>(self, sign: F) -> Result<InvoiceRequest, SignError<E>>
|
||||
where
|
||||
F: FnOnce(&Message) -> Result<Signature, E>
|
||||
{
|
||||
// Use the offer bytes instead of the offer TLV stream as the offer may have contained
|
||||
// unknown TLV records, which are not stored in `OfferContents`.
|
||||
let (payer_tlv_stream, _offer_tlv_stream, invoice_request_tlv_stream) =
|
||||
self.invoice_request.as_tlv_stream();
|
||||
let offer_bytes = WithoutLength(&self.offer.bytes);
|
||||
let unsigned_tlv_stream = (payer_tlv_stream, offer_bytes, invoice_request_tlv_stream);
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
unsigned_tlv_stream.write(&mut bytes).unwrap();
|
||||
|
||||
let pubkey = self.invoice_request.payer_id;
|
||||
let signature = Some(merkle::sign_message(sign, SIGNATURE_TAG, &bytes, pubkey)?);
|
||||
|
||||
// Append the signature TLV record to the bytes.
|
||||
let signature_tlv_stream = SignatureTlvStreamRef {
|
||||
signature: signature.as_ref(),
|
||||
};
|
||||
signature_tlv_stream.write(&mut bytes).unwrap();
|
||||
|
||||
Ok(InvoiceRequest {
|
||||
bytes,
|
||||
contents: self.invoice_request,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An `InvoiceRequest` is a request for an `Invoice` formulated from an [`Offer`].
|
||||
///
|
||||
/// An offer may provide choices such as quantity, amount, chain, features, etc. An invoice request
|
||||
|
@ -61,17 +246,14 @@ impl InvoiceRequest {
|
|||
}
|
||||
|
||||
/// A chain from [`Offer::chains`] that the offer is valid for.
|
||||
///
|
||||
/// [`Offer::chains`]: crate::offers::offer::Offer::chains
|
||||
pub fn chain(&self) -> ChainHash {
|
||||
self.contents.chain.unwrap_or_else(|| self.contents.offer.implied_chain())
|
||||
self.contents.chain()
|
||||
}
|
||||
|
||||
/// The amount to pay in msats (i.e., the minimum lightning-payable unit for [`chain`]), which
|
||||
/// must be greater than or equal to [`Offer::amount`], converted if necessary.
|
||||
///
|
||||
/// [`chain`]: Self::chain
|
||||
/// [`Offer::amount`]: crate::offers::offer::Offer::amount
|
||||
pub fn amount_msats(&self) -> Option<u64> {
|
||||
self.contents.amount_msats
|
||||
}
|
||||
|
@ -82,8 +264,6 @@ impl InvoiceRequest {
|
|||
}
|
||||
|
||||
/// The quantity of the offer's item conforming to [`Offer::is_valid_quantity`].
|
||||
///
|
||||
/// [`Offer::is_valid_quantity`]: crate::offers::offer::Offer::is_valid_quantity
|
||||
pub fn quantity(&self) -> Option<u64> {
|
||||
self.contents.quantity
|
||||
}
|
||||
|
@ -93,7 +273,8 @@ impl InvoiceRequest {
|
|||
self.contents.payer_id
|
||||
}
|
||||
|
||||
/// Payer provided note to include in the invoice.
|
||||
/// A payer-provided note which will be seen by the recipient and reflected back in the invoice
|
||||
/// response.
|
||||
pub fn payer_note(&self) -> Option<PrintableString> {
|
||||
self.contents.payer_note.as_ref().map(|payer_note| PrintableString(payer_note.as_str()))
|
||||
}
|
||||
|
@ -106,12 +287,48 @@ impl InvoiceRequest {
|
|||
}
|
||||
}
|
||||
|
||||
impl InvoiceRequestContents {
|
||||
fn chain(&self) -> ChainHash {
|
||||
self.chain.unwrap_or_else(|| self.offer.implied_chain())
|
||||
}
|
||||
|
||||
pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef {
|
||||
let payer = PayerTlvStreamRef {
|
||||
metadata: Some(&self.payer.0),
|
||||
};
|
||||
|
||||
let offer = self.offer.as_tlv_stream();
|
||||
|
||||
let features = {
|
||||
if self.features == InvoiceRequestFeatures::empty() { None }
|
||||
else { Some(&self.features) }
|
||||
};
|
||||
|
||||
let invoice_request = InvoiceRequestTlvStreamRef {
|
||||
chain: self.chain.as_ref(),
|
||||
amount: self.amount_msats,
|
||||
features,
|
||||
quantity: self.quantity,
|
||||
payer_id: Some(&self.payer_id),
|
||||
payer_note: self.payer_note.as_ref(),
|
||||
};
|
||||
|
||||
(payer, offer, invoice_request)
|
||||
}
|
||||
}
|
||||
|
||||
impl Writeable for InvoiceRequest {
|
||||
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
|
||||
WithoutLength(&self.bytes).write(writer)
|
||||
}
|
||||
}
|
||||
|
||||
impl Writeable for InvoiceRequestContents {
|
||||
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
|
||||
self.as_tlv_stream().write(writer)
|
||||
}
|
||||
}
|
||||
|
||||
tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef, 80..160, {
|
||||
(80, chain: ChainHash),
|
||||
(82, amount: (u64, HighZeroBytesDroppedBigSize)),
|
||||
|
@ -137,6 +354,12 @@ impl SeekReadable for FullInvoiceRequestTlvStream {
|
|||
|
||||
type PartialInvoiceRequestTlvStream = (PayerTlvStream, OfferTlvStream, InvoiceRequestTlvStream);
|
||||
|
||||
type PartialInvoiceRequestTlvStreamRef<'a> = (
|
||||
PayerTlvStreamRef<'a>,
|
||||
OfferTlvStreamRef<'a>,
|
||||
InvoiceRequestTlvStreamRef<'a>,
|
||||
);
|
||||
|
||||
impl TryFrom<Vec<u8>> for InvoiceRequest {
|
||||
type Error = ParseError;
|
||||
|
||||
|
@ -152,8 +375,7 @@ impl TryFrom<Vec<u8>> for InvoiceRequest {
|
|||
)?;
|
||||
|
||||
if let Some(signature) = &signature {
|
||||
let tag = concat!("lightning", "invoice_request", "signature");
|
||||
merkle::verify_signature(signature, tag, &bytes, contents.payer_id)?;
|
||||
merkle::verify_signature(signature, SIGNATURE_TAG, &bytes, contents.payer_id)?;
|
||||
}
|
||||
|
||||
Ok(InvoiceRequest { bytes, contents, signature })
|
||||
|
@ -180,39 +402,58 @@ impl TryFrom<PartialInvoiceRequestTlvStream> for InvoiceRequestContents {
|
|||
return Err(SemanticError::UnsupportedChain);
|
||||
}
|
||||
|
||||
let amount_msats = match (offer.amount(), amount) {
|
||||
(None, None) => return Err(SemanticError::MissingAmount),
|
||||
(Some(Amount::Currency { .. }), _) => return Err(SemanticError::UnsupportedCurrency),
|
||||
(_, amount_msats) => amount_msats,
|
||||
};
|
||||
if offer.amount().is_none() && amount.is_none() {
|
||||
return Err(SemanticError::MissingAmount);
|
||||
}
|
||||
|
||||
offer.check_quantity(quantity)?;
|
||||
offer.check_amount_msats_for_quantity(amount, quantity)?;
|
||||
|
||||
let features = features.unwrap_or_else(InvoiceRequestFeatures::empty);
|
||||
|
||||
let expects_quantity = offer.expects_quantity();
|
||||
let quantity = match quantity {
|
||||
None if expects_quantity => return Err(SemanticError::MissingQuantity),
|
||||
Some(_) if !expects_quantity => return Err(SemanticError::UnexpectedQuantity),
|
||||
Some(quantity) if !offer.is_valid_quantity(quantity) => {
|
||||
return Err(SemanticError::InvalidQuantity);
|
||||
}
|
||||
quantity => quantity,
|
||||
};
|
||||
|
||||
{
|
||||
let amount_msats = amount_msats.unwrap_or(offer.amount_msats());
|
||||
let quantity = quantity.unwrap_or(1);
|
||||
if amount_msats < offer.expected_invoice_amount_msats(quantity) {
|
||||
return Err(SemanticError::InsufficientAmount);
|
||||
}
|
||||
}
|
||||
|
||||
let payer_id = match payer_id {
|
||||
None => return Err(SemanticError::MissingPayerId),
|
||||
Some(payer_id) => payer_id,
|
||||
};
|
||||
|
||||
Ok(InvoiceRequestContents {
|
||||
payer, offer, chain, amount_msats, features, quantity, payer_id, payer_note,
|
||||
payer, offer, chain, amount_msats: amount, features, quantity, payer_id, payer_note,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::InvoiceRequest;
|
||||
|
||||
use bitcoin::secp256k1::{KeyPair, Secp256k1, SecretKey};
|
||||
use core::convert::{Infallible, TryFrom};
|
||||
use crate::ln::msgs::DecodeError;
|
||||
use crate::offers::offer::OfferBuilder;
|
||||
use crate::offers::parse::ParseError;
|
||||
use crate::util::ser::{BigSize, Writeable};
|
||||
|
||||
#[test]
|
||||
fn fails_parsing_invoice_request_with_extra_tlv_records() {
|
||||
let secp_ctx = Secp256k1::new();
|
||||
let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
|
||||
let invoice_request = OfferBuilder::new("foo".into(), keys.public_key())
|
||||
.amount_msats(1000)
|
||||
.build().unwrap()
|
||||
.request_invoice(vec![1; 32], keys.public_key()).unwrap()
|
||||
.build().unwrap()
|
||||
.sign::<_, Infallible>(|digest| Ok(secp_ctx.sign_schnorr_no_aux_rand(digest, &keys)))
|
||||
.unwrap();
|
||||
|
||||
let mut encoded_invoice_request = Vec::new();
|
||||
invoice_request.write(&mut encoded_invoice_request).unwrap();
|
||||
BigSize(1002).write(&mut encoded_invoice_request).unwrap();
|
||||
BigSize(32).write(&mut encoded_invoice_request).unwrap();
|
||||
[42u8; 32].write(&mut encoded_invoice_request).unwrap();
|
||||
|
||||
match InvoiceRequest::try_from(encoded_invoice_request) {
|
||||
Ok(_) => panic!("expected error"),
|
||||
Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,35 @@ tlv_stream!(SignatureTlvStream, SignatureTlvStreamRef, SIGNATURE_TYPES, {
|
|||
(240, signature: Signature),
|
||||
});
|
||||
|
||||
/// Error when signing messages.
|
||||
#[derive(Debug)]
|
||||
pub enum SignError<E> {
|
||||
/// User-defined error when signing the message.
|
||||
Signing(E),
|
||||
/// Error when verifying the produced signature using the given pubkey.
|
||||
Verification(secp256k1::Error),
|
||||
}
|
||||
|
||||
/// Signs a message digest consisting of a tagged hash of the given bytes, checking if it can be
|
||||
/// verified with the supplied pubkey.
|
||||
///
|
||||
/// Panics if `bytes` is not a well-formed TLV stream containing at least one TLV record.
|
||||
pub(super) fn sign_message<F, E>(
|
||||
sign: F, tag: &str, bytes: &[u8], pubkey: PublicKey,
|
||||
) -> Result<Signature, SignError<E>>
|
||||
where
|
||||
F: FnOnce(&Message) -> Result<Signature, E>
|
||||
{
|
||||
let digest = message_digest(tag, bytes);
|
||||
let signature = sign(&digest).map_err(|e| SignError::Signing(e))?;
|
||||
|
||||
let pubkey = pubkey.into();
|
||||
let secp_ctx = Secp256k1::verification_only();
|
||||
secp_ctx.verify_schnorr(&signature, &digest, &pubkey).map_err(|e| SignError::Verification(e))?;
|
||||
|
||||
Ok(signature)
|
||||
}
|
||||
|
||||
/// Verifies the signature with a pubkey over the given bytes using a tagged hash as the message
|
||||
/// digest.
|
||||
///
|
||||
|
@ -31,14 +60,18 @@ tlv_stream!(SignatureTlvStream, SignatureTlvStreamRef, SIGNATURE_TYPES, {
|
|||
pub(super) fn verify_signature(
|
||||
signature: &Signature, tag: &str, bytes: &[u8], pubkey: PublicKey,
|
||||
) -> Result<(), secp256k1::Error> {
|
||||
let tag = sha256::Hash::hash(tag.as_bytes());
|
||||
let merkle_root = root_hash(bytes);
|
||||
let digest = Message::from_slice(&tagged_hash(tag, merkle_root)).unwrap();
|
||||
let digest = message_digest(tag, bytes);
|
||||
let pubkey = pubkey.into();
|
||||
let secp_ctx = Secp256k1::verification_only();
|
||||
secp_ctx.verify_schnorr(signature, &digest, &pubkey)
|
||||
}
|
||||
|
||||
fn message_digest(tag: &str, bytes: &[u8]) -> Message {
|
||||
let tag = sha256::Hash::hash(tag.as_bytes());
|
||||
let merkle_root = root_hash(bytes);
|
||||
Message::from_slice(&tagged_hash(tag, merkle_root)).unwrap()
|
||||
}
|
||||
|
||||
/// Computes a merkle root hash for the given data, which must be a well-formed TLV stream
|
||||
/// containing at least one TLV record.
|
||||
fn root_hash(data: &[u8]) -> sha256::Hash {
|
||||
|
|
|
@ -76,6 +76,7 @@ use core::time::Duration;
|
|||
use crate::io;
|
||||
use crate::ln::features::OfferFeatures;
|
||||
use crate::ln::msgs::MAX_VALUE_MSAT;
|
||||
use crate::offers::invoice_request::InvoiceRequestBuilder;
|
||||
use crate::offers::parse::{Bech32Encode, ParseError, ParsedMessage, SemanticError};
|
||||
use crate::onion_message::BlindedPath;
|
||||
use crate::util::ser::{HighZeroBytesDroppedBigSize, WithoutLength, Writeable, Writer};
|
||||
|
@ -149,15 +150,6 @@ impl OfferBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Offer::features`].
|
||||
///
|
||||
/// Successive calls to this method will override the previous setting.
|
||||
#[cfg(test)]
|
||||
pub fn features(mut self, features: OfferFeatures) -> Self {
|
||||
self.offer.features = features;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the [`Offer::absolute_expiry`] as seconds since the Unix epoch. Any expiry that has
|
||||
/// already passed is valid and can be checked for using [`Offer::is_expired`].
|
||||
///
|
||||
|
@ -222,6 +214,14 @@ impl OfferBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl OfferBuilder {
|
||||
fn features_unchecked(mut self, features: OfferFeatures) -> Self {
|
||||
self.offer.features = features;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An `Offer` is a potentially long-lived proposal for payment of a good or service.
|
||||
///
|
||||
/// An offer is a precursor to an [`InvoiceRequest`]. A merchant publishes an offer from which a
|
||||
|
@ -238,8 +238,8 @@ impl OfferBuilder {
|
|||
pub struct Offer {
|
||||
// The serialized offer. Needed when creating an `InvoiceRequest` if the offer contains unknown
|
||||
// fields.
|
||||
bytes: Vec<u8>,
|
||||
contents: OfferContents,
|
||||
pub(super) bytes: Vec<u8>,
|
||||
pub(super) contents: OfferContents,
|
||||
}
|
||||
|
||||
/// The contents of an [`Offer`], which may be shared with an [`InvoiceRequest`] or an `Invoice`.
|
||||
|
@ -270,6 +270,10 @@ impl Offer {
|
|||
self.contents.chains()
|
||||
}
|
||||
|
||||
pub(super) fn implied_chain(&self) -> ChainHash {
|
||||
self.contents.implied_chain()
|
||||
}
|
||||
|
||||
/// Returns whether the given chain is supported by the offer.
|
||||
pub fn supports_chain(&self, chain: ChainHash) -> bool {
|
||||
self.contents.supports_chain(chain)
|
||||
|
@ -351,6 +355,29 @@ impl Offer {
|
|||
self.contents.signing_pubkey.unwrap()
|
||||
}
|
||||
|
||||
/// Creates an [`InvoiceRequest`] for the offer with the given `metadata` and `payer_id`, which
|
||||
/// will be reflected in the `Invoice` response.
|
||||
///
|
||||
/// The `metadata` is useful for including information about the derivation of `payer_id` such
|
||||
/// that invoice response handling can be stateless. Also serves as payer-provided entropy while
|
||||
/// hashing in the signature calculation.
|
||||
///
|
||||
/// This should not leak any information such as by using a simple BIP-32 derivation path.
|
||||
/// Otherwise, payments may be correlated.
|
||||
///
|
||||
/// Errors if the offer contains unknown required features.
|
||||
///
|
||||
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
|
||||
pub fn request_invoice(
|
||||
&self, metadata: Vec<u8>, payer_id: PublicKey
|
||||
) -> Result<InvoiceRequestBuilder, SemanticError> {
|
||||
if self.features().requires_unknown_bits() {
|
||||
return Err(SemanticError::UnknownRequiredFeatures);
|
||||
}
|
||||
|
||||
Ok(InvoiceRequestBuilder::new(self, metadata, payer_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn as_tlv_stream(&self) -> OfferTlvStreamRef {
|
||||
self.contents.as_tlv_stream()
|
||||
|
@ -380,23 +407,48 @@ impl OfferContents {
|
|||
self.amount.as_ref()
|
||||
}
|
||||
|
||||
pub fn amount_msats(&self) -> u64 {
|
||||
match self.amount() {
|
||||
pub(super) fn check_amount_msats_for_quantity(
|
||||
&self, amount_msats: Option<u64>, quantity: Option<u64>
|
||||
) -> Result<(), SemanticError> {
|
||||
let offer_amount_msats = match self.amount {
|
||||
None => 0,
|
||||
Some(&Amount::Bitcoin { amount_msats }) => amount_msats,
|
||||
Some(&Amount::Currency { .. }) => unreachable!(),
|
||||
}
|
||||
}
|
||||
Some(Amount::Bitcoin { amount_msats }) => amount_msats,
|
||||
Some(Amount::Currency { .. }) => return Err(SemanticError::UnsupportedCurrency),
|
||||
};
|
||||
|
||||
pub fn expected_invoice_amount_msats(&self, quantity: u64) -> u64 {
|
||||
self.amount_msats() * quantity
|
||||
if !self.expects_quantity() || quantity.is_some() {
|
||||
let expected_amount_msats = offer_amount_msats * quantity.unwrap_or(1);
|
||||
let amount_msats = amount_msats.unwrap_or(expected_amount_msats);
|
||||
|
||||
if amount_msats < expected_amount_msats {
|
||||
return Err(SemanticError::InsufficientAmount);
|
||||
}
|
||||
|
||||
if amount_msats > MAX_VALUE_MSAT {
|
||||
return Err(SemanticError::InvalidAmount);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn supported_quantity(&self) -> Quantity {
|
||||
self.supported_quantity
|
||||
}
|
||||
|
||||
pub fn is_valid_quantity(&self, quantity: u64) -> bool {
|
||||
pub(super) fn check_quantity(&self, quantity: Option<u64>) -> Result<(), SemanticError> {
|
||||
let expects_quantity = self.expects_quantity();
|
||||
match quantity {
|
||||
None if expects_quantity => Err(SemanticError::MissingQuantity),
|
||||
Some(_) if !expects_quantity => Err(SemanticError::UnexpectedQuantity),
|
||||
Some(quantity) if !self.is_valid_quantity(quantity) => {
|
||||
Err(SemanticError::InvalidQuantity)
|
||||
},
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_quantity(&self, quantity: u64) -> bool {
|
||||
match self.supported_quantity {
|
||||
Quantity::Bounded(n) => {
|
||||
let n = n.get();
|
||||
|
@ -407,14 +459,14 @@ impl OfferContents {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn expects_quantity(&self) -> bool {
|
||||
fn expects_quantity(&self) -> bool {
|
||||
match self.supported_quantity {
|
||||
Quantity::Bounded(n) => n.get() != 1,
|
||||
Quantity::Unbounded => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_tlv_stream(&self) -> OfferTlvStreamRef {
|
||||
pub(super) fn as_tlv_stream(&self) -> OfferTlvStreamRef {
|
||||
let (currency, amount) = match &self.amount {
|
||||
None => (None, None),
|
||||
Some(Amount::Bitcoin { amount_msats }) => (None, Some(*amount_msats)),
|
||||
|
@ -760,15 +812,15 @@ mod tests {
|
|||
#[test]
|
||||
fn builds_offer_with_features() {
|
||||
let offer = OfferBuilder::new("foo".into(), pubkey(42))
|
||||
.features(OfferFeatures::unknown())
|
||||
.features_unchecked(OfferFeatures::unknown())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(offer.features(), &OfferFeatures::unknown());
|
||||
assert_eq!(offer.as_tlv_stream().features, Some(&OfferFeatures::unknown()));
|
||||
|
||||
let offer = OfferBuilder::new("foo".into(), pubkey(42))
|
||||
.features(OfferFeatures::unknown())
|
||||
.features(OfferFeatures::empty())
|
||||
.features_unchecked(OfferFeatures::unknown())
|
||||
.features_unchecked(OfferFeatures::empty())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(offer.features(), &OfferFeatures::empty());
|
||||
|
@ -890,6 +942,18 @@ mod tests {
|
|||
assert_eq!(tlv_stream.quantity_max, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_requesting_invoice_with_unknown_required_features() {
|
||||
match OfferBuilder::new("foo".into(), pubkey(42))
|
||||
.features_unchecked(OfferFeatures::unknown())
|
||||
.build().unwrap()
|
||||
.request_invoice(vec![1; 32], pubkey(43))
|
||||
{
|
||||
Ok(_) => panic!("expected error"),
|
||||
Err(e) => assert_eq!(e, SemanticError::UnknownRequiredFeatures),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_offer_with_chains() {
|
||||
let offer = OfferBuilder::new("foo".into(), pubkey(42))
|
||||
|
|
|
@ -123,6 +123,8 @@ pub enum ParseError {
|
|||
/// Error when interpreting a TLV stream as a specific type.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum SemanticError {
|
||||
/// The current [`std::time::SystemTime`] is past the offer or invoice's expiration.
|
||||
AlreadyExpired,
|
||||
/// The provided chain hash does not correspond to a supported chain.
|
||||
UnsupportedChain,
|
||||
/// An amount was expected but was missing.
|
||||
|
@ -133,6 +135,8 @@ pub enum SemanticError {
|
|||
InsufficientAmount,
|
||||
/// A currency was provided that is not supported.
|
||||
UnsupportedCurrency,
|
||||
/// A feature was required but is unknown.
|
||||
UnknownRequiredFeatures,
|
||||
/// A required description was not provided.
|
||||
MissingDescription,
|
||||
/// A signing pubkey was not provided.
|
||||
|
|
Loading…
Add table
Reference in a new issue