Skip to content

Commit 8fd7637

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 8ba09e0 commit 8fd7637

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::*;
@@ -321,6 +339,12 @@ impl Offer {
321339
}
322340
}
323341

342+
impl AsRef<[u8]> for Offer {
343+
fn as_ref(&self) -> &[u8] {
344+
&self.bytes
345+
}
346+
}
347+
324348
impl OfferContents {
325349
pub fn implied_chain(&self) -> ChainHash {
326350
ChainHash::using_genesis_block(Network::Bitcoin)
@@ -359,12 +383,27 @@ impl OfferContents {
359383
}
360384
}
361385

386+
impl Writeable for Offer {
387+
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
388+
WithoutLength(&self.bytes).write(writer)
389+
}
390+
}
391+
362392
impl Writeable for OfferContents {
363393
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
364394
self.as_tlv_stream().write(writer)
365395
}
366396
}
367397

398+
impl TryFrom<Vec<u8>> for Offer {
399+
type Error = ParseError;
400+
401+
fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
402+
let tlv_stream: OfferTlvStream = Readable::read(&mut &bytes[..])?;
403+
Offer::try_from((bytes, tlv_stream))
404+
}
405+
}
406+
368407
/// The minimum amount required for an item in an [`Offer`], denominated in either bitcoin or
369408
/// another currency.
370409
#[derive(Clone, Debug, PartialEq)]
@@ -400,6 +439,15 @@ impl Quantity {
400439
Quantity::Bounded(NonZeroU64::new(1).unwrap())
401440
}
402441

442+
fn new(quantity: Option<u64>) -> Self {
443+
match quantity {
444+
None => Quantity::one(),
445+
Some(0) => Quantity::Unbounded,
446+
Some(1) => unreachable!(),
447+
Some(n) => Quantity::Bounded(NonZeroU64::new(n).unwrap()),
448+
}
449+
}
450+
403451
fn to_tlv_record(&self) -> Option<u64> {
404452
match self {
405453
Quantity::Bounded(n) => {
@@ -425,13 +473,91 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, {
425473
(22, node_id: PublicKey),
426474
});
427475

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

432557
use bitcoin::blockdata::constants::ChainHash;
433558
use bitcoin::network::constants::Network;
434559
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
560+
use core::convert::TryFrom;
435561
use core::num::NonZeroU64;
436562
use core::time::Duration;
437563
use crate::ln::features::OfferFeatures;
@@ -454,7 +580,7 @@ mod tests {
454580
let offer = OfferBuilder::new("foo".into(), pubkey(42)).build().unwrap();
455581
let tlv_stream = offer.as_tlv_stream();
456582
let mut buffer = Vec::new();
457-
offer.contents.write(&mut buffer).unwrap();
583+
offer.write(&mut buffer).unwrap();
458584

459585
assert_eq!(offer.bytes, buffer.as_slice());
460586
assert_eq!(offer.chains(), vec![ChainHash::using_genesis_block(Network::Bitcoin)]);
@@ -481,6 +607,10 @@ mod tests {
481607
assert_eq!(tlv_stream.issuer, None);
482608
assert_eq!(tlv_stream.quantity_max, None);
483609
assert_eq!(tlv_stream.node_id, Some(&pubkey(42)));
610+
611+
if let Err(e) = Offer::try_from(buffer) {
612+
panic!("error parsing offer: {:?}", e);
613+
}
484614
}
485615

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

0 commit comments

Comments
 (0)