Skip to content

Commit

Permalink
Merge pull request lightningdevkit#3535 from jkczyz/2025-01-invoice-a…
Browse files Browse the repository at this point in the history
…mount

Validate `amount_msats` against invreq amount
  • Loading branch information
TheBlueMatt authored Jan 15, 2025
2 parents bcbff65 + c2360be commit 6d604c5
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 11 deletions.
12 changes: 6 additions & 6 deletions lightning/src/ln/offers_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() {
human_readable_name: None,
},
});
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(charlie_id));

Expand Down Expand Up @@ -727,7 +727,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
human_readable_name: None,
},
});
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(bob_id));

Expand Down Expand Up @@ -1116,7 +1116,7 @@ fn creates_and_pays_for_offer_with_retry() {
human_readable_name: None,
},
});
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(bob_id));
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
Expand Down Expand Up @@ -1411,7 +1411,7 @@ fn fails_authentication_when_handling_invoice_request() {
alice.onion_messenger.handle_onion_message(david_id, &onion_message);

let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(charlie_id));

Expand Down Expand Up @@ -1441,7 +1441,7 @@ fn fails_authentication_when_handling_invoice_request() {
alice.onion_messenger.handle_onion_message(bob_id, &onion_message);

let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(charlie_id));

Expand Down Expand Up @@ -1543,7 +1543,7 @@ fn fails_authentication_when_handling_invoice_for_offer() {
alice.onion_messenger.handle_onion_message(bob_id, &onion_message);

let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
assert_eq!(reply_path.introduction_node(), &IntroductionNode::NodeId(charlie_id));

Expand Down
77 changes: 76 additions & 1 deletion lightning/src/offers/invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ macro_rules! invoice_builder_methods { (
pub(crate) fn amount_msats(
invoice_request: &InvoiceRequest
) -> Result<u64, Bolt12SemanticError> {
match invoice_request.amount_msats() {
match invoice_request.contents.inner.amount_msats() {
Some(amount_msats) => Ok(amount_msats),
None => match invoice_request.contents.inner.offer.amount() {
Some(Amount::Bitcoin { amount_msats }) => {
Expand Down Expand Up @@ -1531,6 +1531,11 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream,
)
)?;

if amount_msats != refund.amount_msats() {
return Err(Bolt12SemanticError::InvalidAmount);
}

Ok(InvoiceContents::ForRefund { refund, fields })
} else {
let invoice_request = InvoiceRequestContents::try_from(
Expand All @@ -1539,6 +1544,13 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
experimental_offer_tlv_stream, experimental_invoice_request_tlv_stream,
)
)?;

if let Some(requested_amount_msats) = invoice_request.amount_msats() {
if amount_msats != requested_amount_msats {
return Err(Bolt12SemanticError::InvalidAmount);
}
}

Ok(InvoiceContents::ForOffer { invoice_request, fields })
}
}
Expand Down Expand Up @@ -2707,6 +2719,69 @@ mod tests {
}
}

#[test]
fn fails_parsing_invoice_with_wrong_amount() {
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 invoice = OfferBuilder::new(recipient_pubkey())
.amount_msats(1000)
.build().unwrap()
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
.build_and_sign().unwrap()
.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap()
.amount_msats_unchecked(2000)
.build().unwrap()
.sign(recipient_sign).unwrap();

let mut buffer = Vec::new();
invoice.write(&mut buffer).unwrap();

match Bolt12Invoice::try_from(buffer) {
Ok(_) => panic!("expected error"),
Err(e) => assert_eq!(e, Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount)),
}

let invoice = OfferBuilder::new(recipient_pubkey())
.amount_msats(1000)
.build().unwrap()
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
.amount_msats(1000).unwrap()
.build_and_sign().unwrap()
.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap()
.amount_msats_unchecked(2000)
.build().unwrap()
.sign(recipient_sign).unwrap();

let mut buffer = Vec::new();
invoice.write(&mut buffer).unwrap();

match Bolt12Invoice::try_from(buffer) {
Ok(_) => panic!("expected error"),
Err(e) => assert_eq!(e, Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount)),
}

let invoice = RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap()
.build().unwrap()
.respond_using_derived_keys_no_std(
payment_paths(), payment_hash(), now(), &expanded_key, &entropy
)
.unwrap()
.amount_msats_unchecked(2000)
.build_and_sign(&secp_ctx).unwrap();

let mut buffer = Vec::new();
invoice.write(&mut buffer).unwrap();

match Bolt12Invoice::try_from(buffer) {
Ok(_) => panic!("expected error"),
Err(e) => assert_eq!(e, Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount)),
}
}

#[test]
fn fails_parsing_invoice_without_signature() {
let expanded_key = ExpandedKey::new([42; 32]);
Expand Down
8 changes: 8 additions & 0 deletions lightning/src/offers/invoice_macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ macro_rules! invoice_builder_methods_test { (
$self: ident, $self_type: ty, $invoice_fields: expr, $return_type: ty, $return_value: expr
$(, $self_mut: tt)?
) => {
#[cfg_attr(c_bindings, allow(dead_code))]
pub(crate) fn amount_msats_unchecked(
$($self_mut)* $self: $self_type, amount_msats: u64,
) -> $return_type {
$invoice_fields.amount_msats = amount_msats;
$return_value
}

#[cfg_attr(c_bindings, allow(dead_code))]
pub(crate) fn features_unchecked(
$($self_mut)* $self: $self_type, features: Bolt12InvoiceFeatures
Expand Down
75 changes: 72 additions & 3 deletions lightning/src/offers/invoice_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ use crate::ln::inbound_payment::{ExpandedKey, IV_LEN};
use crate::ln::msgs::DecodeError;
use crate::offers::merkle::{SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, self, SIGNATURE_TLV_RECORD_SIZE};
use crate::offers::nonce::Nonce;
use crate::offers::offer::{EXPERIMENTAL_OFFER_TYPES, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OFFER_TYPES, Offer, OfferContents, OfferId, OfferTlvStream, OfferTlvStreamRef};
use crate::offers::offer::{Amount, EXPERIMENTAL_OFFER_TYPES, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OFFER_TYPES, Offer, OfferContents, OfferId, OfferTlvStream, OfferTlvStreamRef};
use crate::offers::parse::{Bolt12ParseError, ParsedMessage, Bolt12SemanticError};
use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef};
use crate::offers::signer::{Metadata, MetadataMaterial};
Expand Down Expand Up @@ -665,6 +665,15 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => {
$contents.amount_msats()
}

/// Returns whether an amount was set in the request; otherwise, if [`amount_msats`] is `Some`
/// then it was inferred from the [`Offer::amount`] and [`quantity`].
///
/// [`amount_msats`]: Self::amount_msats
/// [`quantity`]: Self::quantity
pub fn has_amount_msats(&$self) -> bool {
$contents.has_amount_msats()
}

/// Features pertaining to requesting an invoice.
pub fn invoice_request_features(&$self) -> &InvoiceRequestFeatures {
&$contents.features()
Expand Down Expand Up @@ -974,7 +983,19 @@ impl InvoiceRequestContents {
}

pub(super) fn amount_msats(&self) -> Option<u64> {
self.inner.amount_msats
self.inner
.amount_msats()
.or_else(|| match self.inner.offer.amount() {
Some(Amount::Bitcoin { amount_msats }) => {
Some(amount_msats.saturating_mul(self.quantity().unwrap_or(1)))
},
Some(Amount::Currency { .. }) => None,
None => { debug_assert!(false); None},
})
}

pub(super) fn has_amount_msats(&self) -> bool {
self.inner.amount_msats().is_some()
}

pub(super) fn features(&self) -> &InvoiceRequestFeatures {
Expand Down Expand Up @@ -1015,6 +1036,10 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey {
self.chain.unwrap_or_else(|| self.offer.implied_chain())
}

pub(super) fn amount_msats(&self) -> Option<u64> {
self.amount_msats
}

pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef {
let payer = PayerTlvStreamRef {
metadata: self.payer.0.as_bytes(),
Expand Down Expand Up @@ -1381,7 +1406,7 @@ mod tests {
assert_eq!(invoice_request.supported_quantity(), Quantity::One);
assert_eq!(invoice_request.issuer_signing_pubkey(), Some(recipient_pubkey()));
assert_eq!(invoice_request.chain(), ChainHash::using_genesis_block(Network::Bitcoin));
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(invoice_request.amount_msats(), Some(1000));
assert_eq!(invoice_request.invoice_request_features(), &InvoiceRequestFeatures::empty());
assert_eq!(invoice_request.quantity(), None);
assert_eq!(invoice_request.payer_note(), None);
Expand Down Expand Up @@ -1657,6 +1682,7 @@ mod tests {
.amount_msats(1000).unwrap()
.build_and_sign().unwrap();
let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream();
assert!(invoice_request.has_amount_msats());
assert_eq!(invoice_request.amount_msats(), Some(1000));
assert_eq!(tlv_stream.amount, Some(1000));

Expand All @@ -1668,6 +1694,7 @@ mod tests {
.amount_msats(1000).unwrap()
.build_and_sign().unwrap();
let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream();
assert!(invoice_request.has_amount_msats());
assert_eq!(invoice_request.amount_msats(), Some(1000));
assert_eq!(tlv_stream.amount, Some(1000));

Expand All @@ -1678,6 +1705,7 @@ mod tests {
.amount_msats(1001).unwrap()
.build_and_sign().unwrap();
let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream();
assert!(invoice_request.has_amount_msats());
assert_eq!(invoice_request.amount_msats(), Some(1001));
assert_eq!(tlv_stream.amount, Some(1001));

Expand Down Expand Up @@ -1748,6 +1776,47 @@ mod tests {
}
}

#[test]
fn builds_invoice_request_without_amount() {
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 invoice_request = OfferBuilder::new(recipient_pubkey())
.amount_msats(1000)
.build().unwrap()
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
.build_and_sign().unwrap();
let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream();
assert!(!invoice_request.has_amount_msats());
assert_eq!(invoice_request.amount_msats(), Some(1000));
assert_eq!(tlv_stream.amount, None);

let invoice_request = OfferBuilder::new(recipient_pubkey())
.amount_msats(1000)
.supported_quantity(Quantity::Unbounded)
.build().unwrap()
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
.quantity(2).unwrap()
.build_and_sign().unwrap();
let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream();
assert!(!invoice_request.has_amount_msats());
assert_eq!(invoice_request.amount_msats(), Some(2000));
assert_eq!(tlv_stream.amount, None);

let invoice_request = OfferBuilder::new(recipient_pubkey())
.amount(Amount::Currency { iso4217_code: *b"USD", amount: 10 })
.build_unchecked()
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
.build_unchecked_and_sign();
let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream();
assert!(!invoice_request.has_amount_msats());
assert_eq!(invoice_request.amount_msats(), None);
assert_eq!(tlv_stream.amount, None);
}

#[test]
fn builds_invoice_request_with_features() {
let expanded_key = ExpandedKey::new([42; 32]);
Expand Down
2 changes: 1 addition & 1 deletion lightning/src/offers/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ pub enum Bolt12SemanticError {
UnexpectedChain,
/// An amount was expected but was missing.
MissingAmount,
/// The amount exceeded the total bitcoin supply.
/// The amount exceeded the total bitcoin supply or didn't match an expected amount.
InvalidAmount,
/// An amount was provided but was not sufficient in value.
InsufficientAmount,
Expand Down

0 comments on commit 6d604c5

Please sign in to comment.