Skip to content

Commit 8e769d4

Browse files
committed
Offer parsing from bech32 strings
Add common bech32 parsing for BOLT 12 messages. The encoding is similar to bech32 only without a checksum and with support for continuing messages across multiple parts. Messages implementing Bech32Encode are parsed into a TLV stream, which is converted to the desired message content while performing semantic checks. Checking after conversion allows for more elaborate checks of data composed of multiple TLV records and for more meaningful error messages. The parsed bytes are also saved to allow creating messages with mirrored data, even if TLV records are unknown.
1 parent 0846064 commit 8e769d4

File tree

4 files changed

+327
-8
lines changed

4 files changed

+327
-8
lines changed

lightning/src/offers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
//! Offers are a flexible protocol for Lightning payments.
1414
1515
pub mod offer;
16+
pub mod parse;

lightning/src/offers/offer.rs

Lines changed: 222 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818
//! extern crate core;
1919
//! extern crate lightning;
2020
//!
21+
//! use core::convert::TryFrom;
2122
//! use core::num::NonZeroU64;
2223
//! use core::time::Duration;
2324
//!
2425
//! use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey};
25-
//! use lightning::offers::offer::{OfferBuilder, Quantity};
26+
//! use lightning::offers::offer::{Offer, OfferBuilder, Quantity};
27+
//! use lightning::offers::parse::ParseError;
28+
//! use lightning::util::ser::{Readable, Writeable};
2629
//!
27-
//! # use bitcoin::secp256k1;
2830
//! # use lightning::onion_message::BlindedPath;
2931
//! # #[cfg(feature = "std")]
3032
//! # use std::time::SystemTime;
@@ -33,9 +35,9 @@
3335
//! # fn create_another_blinded_path() -> BlindedPath { unimplemented!() }
3436
//! #
3537
//! # #[cfg(feature = "std")]
36-
//! # fn build() -> Result<(), secp256k1::Error> {
38+
//! # fn build() -> Result<(), ParseError> {
3739
//! let secp_ctx = Secp256k1::new();
38-
//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32])?);
40+
//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
3941
//! let pubkey = PublicKey::from(keys);
4042
//!
4143
//! let expiration = SystemTime::now() + Duration::from_secs(24 * 60 * 60);
@@ -48,20 +50,36 @@
4850
//! .path(create_another_blinded_path())
4951
//! .build()
5052
//! .unwrap();
53+
//!
54+
//! // Encode as a bech32 string for use in a QR code.
55+
//! let encoded_offer = offer.to_string();
56+
//!
57+
//! // Parse from a bech32 string after scanning from a QR code.
58+
//! let offer = encoded_offer.parse::<Offer>()?;
59+
//!
60+
//! // Encode offer as raw bytes.
61+
//! let mut bytes = Vec::new();
62+
//! offer.write(&mut bytes).unwrap();
63+
//!
64+
//! // Decode raw bytes into an offer.
65+
//! let offer = Offer::try_from(bytes)?;
5166
//! # Ok(())
5267
//! # }
5368
//! ```
5469
5570
use bitcoin::blockdata::constants::ChainHash;
5671
use bitcoin::network::constants::Network;
5772
use bitcoin::secp256k1::PublicKey;
73+
use core::convert::TryFrom;
5874
use core::num::NonZeroU64;
75+
use core::str::FromStr;
5976
use core::time::Duration;
6077
use crate::io;
6178
use crate::ln::features::OfferFeatures;
6279
use crate::ln::msgs::MAX_VALUE_MSAT;
80+
use crate::offers::parse::{Bech32Encode, ParseError, SemanticError};
6381
use crate::onion_message::BlindedPath;
64-
use crate::util::ser::{HighZeroBytesDroppedBigSize, WithoutLength, Writeable, Writer};
82+
use crate::util::ser::{HighZeroBytesDroppedBigSize, Readable, WithoutLength, Writeable, Writer};
6583
use crate::util::string::PrintableString;
6684

6785
use crate::prelude::*;
@@ -315,6 +333,12 @@ impl Offer {
315333
}
316334
}
317335

336+
impl AsRef<[u8]> for Offer {
337+
fn as_ref(&self) -> &[u8] {
338+
&self.bytes
339+
}
340+
}
341+
318342
impl OfferContents {
319343
pub fn supported_quantity(&self) -> Quantity {
320344
self.supported_quantity
@@ -349,12 +373,27 @@ impl OfferContents {
349373
}
350374
}
351375

376+
impl Writeable for Offer {
377+
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
378+
WithoutLength(&self.bytes).write(writer)
379+
}
380+
}
381+
352382
impl Writeable for OfferContents {
353383
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
354384
self.as_tlv_stream().write(writer)
355385
}
356386
}
357387

388+
impl TryFrom<Vec<u8>> for Offer {
389+
type Error = ParseError;
390+
391+
fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
392+
let tlv_stream: OfferTlvStream = Readable::read(&mut &bytes[..])?;
393+
Offer::try_from((bytes, tlv_stream))
394+
}
395+
}
396+
358397
/// The minimum amount required for an item in an [`Offer`], denominated in either bitcoin or
359398
/// another currency.
360399
#[derive(Clone, Debug, PartialEq)]
@@ -390,6 +429,15 @@ impl Quantity {
390429
Quantity::Bounded(NonZeroU64::new(1).unwrap())
391430
}
392431

432+
fn new(quantity: Option<u64>) -> Self {
433+
match quantity {
434+
None => Quantity::one(),
435+
Some(0) => Quantity::Unbounded,
436+
Some(1) => unreachable!(),
437+
Some(n) => Quantity::Bounded(NonZeroU64::new(n).unwrap()),
438+
}
439+
}
440+
393441
fn to_tlv_record(&self) -> Option<u64> {
394442
match self {
395443
Quantity::Bounded(n) => {
@@ -415,13 +463,91 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, {
415463
(22, node_id: PublicKey),
416464
});
417465

466+
impl Bech32Encode for Offer {
467+
const BECH32_HRP: &'static str = "lno";
468+
}
469+
470+
type ParsedOffer = (Vec<u8>, OfferTlvStream);
471+
472+
impl FromStr for Offer {
473+
type Err = ParseError;
474+
475+
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
476+
Self::from_bech32_str(s)
477+
}
478+
}
479+
480+
impl TryFrom<ParsedOffer> for Offer {
481+
type Error = ParseError;
482+
483+
fn try_from(offer: ParsedOffer) -> Result<Self, Self::Error> {
484+
let (bytes, tlv_stream) = offer;
485+
let contents = OfferContents::try_from(tlv_stream)?;
486+
Ok(Offer { bytes, contents })
487+
}
488+
}
489+
490+
impl TryFrom<OfferTlvStream> for OfferContents {
491+
type Error = SemanticError;
492+
493+
fn try_from(tlv_stream: OfferTlvStream) -> Result<Self, Self::Error> {
494+
let OfferTlvStream {
495+
chains, metadata, currency, amount, description, features, absolute_expiry, paths,
496+
issuer, quantity_max, node_id,
497+
} = tlv_stream;
498+
499+
let amount = match (currency, amount) {
500+
(None, None) => None,
501+
(None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }),
502+
(Some(_), None) => return Err(SemanticError::MissingAmount),
503+
(Some(iso4217_code), Some(amount)) => Some(Amount::Currency { iso4217_code, amount }),
504+
};
505+
506+
let description = match description {
507+
None => return Err(SemanticError::MissingDescription),
508+
Some(description) => description,
509+
};
510+
511+
let features = features.unwrap_or_else(OfferFeatures::empty);
512+
513+
let absolute_expiry = absolute_expiry
514+
.map(|seconds_from_epoch| Duration::from_secs(seconds_from_epoch));
515+
516+
let paths = match paths {
517+
Some(paths) if paths.is_empty() => return Err(SemanticError::MissingPaths),
518+
paths => paths,
519+
};
520+
521+
let supported_quantity = match quantity_max {
522+
Some(1) => return Err(SemanticError::InvalidQuantity),
523+
_ => Quantity::new(quantity_max),
524+
};
525+
526+
if node_id.is_none() {
527+
return Err(SemanticError::MissingNodeId);
528+
}
529+
530+
Ok(OfferContents {
531+
chains, metadata, amount, description, features, absolute_expiry, issuer, paths,
532+
supported_quantity, signing_pubkey: node_id,
533+
})
534+
}
535+
}
536+
537+
impl core::fmt::Display for Offer {
538+
fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
539+
self.fmt_bech32_str(f)
540+
}
541+
}
542+
418543
#[cfg(test)]
419544
mod tests {
420-
use super::{Amount, OfferBuilder, Quantity};
545+
use super::{Amount, Offer, OfferBuilder, Quantity};
421546

422547
use bitcoin::blockdata::constants::ChainHash;
423548
use bitcoin::network::constants::Network;
424549
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
550+
use core::convert::TryFrom;
425551
use core::num::NonZeroU64;
426552
use core::time::Duration;
427553
use crate::ln::features::OfferFeatures;
@@ -444,7 +570,7 @@ mod tests {
444570
let offer = OfferBuilder::new("foo".into(), pubkey(42)).build().unwrap();
445571
let tlv_stream = offer.as_tlv_stream();
446572
let mut buffer = Vec::new();
447-
offer.contents.write(&mut buffer).unwrap();
573+
offer.write(&mut buffer).unwrap();
448574

449575
assert_eq!(offer.bytes, buffer.as_slice());
450576
assert_eq!(offer.chains(), vec![ChainHash::using_genesis_block(Network::Bitcoin)]);
@@ -471,6 +597,10 @@ mod tests {
471597
assert_eq!(tlv_stream.issuer, None);
472598
assert_eq!(tlv_stream.quantity_max, None);
473599
assert_eq!(tlv_stream.node_id, Some(&pubkey(42)));
600+
601+
if let Err(e) = Offer::try_from(buffer) {
602+
panic!("error parsing offer: {:?}", e);
603+
}
474604
}
475605

476606
#[test]
@@ -693,3 +823,88 @@ mod tests {
693823
assert_eq!(tlv_stream.quantity_max, None);
694824
}
695825
}
826+
827+
#[cfg(test)]
828+
mod bolt12_tests {
829+
use super::{Offer, ParseError};
830+
use bitcoin::bech32;
831+
use crate::ln::msgs::DecodeError;
832+
833+
// TODO: Remove once test vectors are updated.
834+
#[ignore]
835+
#[test]
836+
fn encodes_offer_as_bech32_without_checksum() {
837+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy";
838+
let offer = dbg!(encoded_offer.parse::<Offer>().unwrap());
839+
let reencoded_offer = offer.to_string();
840+
dbg!(reencoded_offer.parse::<Offer>().unwrap());
841+
assert_eq!(reencoded_offer, encoded_offer);
842+
}
843+
844+
// TODO: Remove once test vectors are updated.
845+
#[ignore]
846+
#[test]
847+
fn parses_bech32_encoded_offers() {
848+
let offers = [
849+
// BOLT 12 test vectors
850+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
851+
"l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
852+
"l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
853+
"lno1qcp4256ypqpq+86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qs+y",
854+
"lno1qcp4256ypqpq+ 86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+ 0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+\nsqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43l+\r\nastpwuh73k29qs+\r y",
855+
// Two blinded paths
856+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yg06qg2qdd7t628sgykwj5kuc837qmlv9m9gr7sq8ap6erfgacv26nhp8zzcqgzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6heqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9sqs9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
857+
];
858+
for encoded_offer in &offers {
859+
if let Err(e) = encoded_offer.parse::<Offer>() {
860+
panic!("Invalid offer ({:?}): {}", e, encoded_offer);
861+
}
862+
}
863+
}
864+
865+
#[test]
866+
fn fails_parsing_bech32_encoded_offers_with_invalid_continuations() {
867+
let offers = [
868+
// BOLT 12 test vectors
869+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+",
870+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+ ",
871+
"+lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
872+
"+ lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
873+
"ln++o1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
874+
];
875+
for encoded_offer in &offers {
876+
match encoded_offer.parse::<Offer>() {
877+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
878+
Err(e) => assert_eq!(e, ParseError::InvalidContinuation),
879+
}
880+
}
881+
882+
}
883+
884+
#[test]
885+
fn fails_parsing_bech32_encoded_offer_with_invalid_hrp() {
886+
let encoded_offer = "lni1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy";
887+
match encoded_offer.parse::<Offer>() {
888+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
889+
Err(e) => assert_eq!(e, ParseError::InvalidBech32Hrp),
890+
}
891+
}
892+
893+
#[test]
894+
fn fails_parsing_bech32_encoded_offer_with_invalid_bech32_data() {
895+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qso";
896+
match encoded_offer.parse::<Offer>() {
897+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
898+
Err(e) => assert_eq!(e, ParseError::Bech32(bech32::Error::InvalidChar('o'))),
899+
}
900+
}
901+
902+
#[test]
903+
fn fails_parsing_bech32_encoded_offer_with_invalid_tlv_data() {
904+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsyqqqqq";
905+
match encoded_offer.parse::<Offer>() {
906+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
907+
Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)),
908+
}
909+
}
910+
}

0 commit comments

Comments
 (0)