Skip to content

Commit 60d7ffc

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 f1428fd commit 60d7ffc

File tree

4 files changed

+340
-8
lines changed

4 files changed

+340
-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: 213 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)]
@@ -425,13 +464,91 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, {
425464
(22, node_id: PublicKey),
426465
});
427466

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

432548
use bitcoin::blockdata::constants::ChainHash;
433549
use bitcoin::network::constants::Network;
434550
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
551+
use core::convert::TryFrom;
435552
use core::num::NonZeroU64;
436553
use core::time::Duration;
437554
use crate::ln::features::OfferFeatures;
@@ -454,7 +571,7 @@ mod tests {
454571
let offer = OfferBuilder::new("foo".into(), pubkey(42)).build().unwrap();
455572
let tlv_stream = offer.as_tlv_stream();
456573
let mut buffer = Vec::new();
457-
offer.contents.write(&mut buffer).unwrap();
574+
offer.write(&mut buffer).unwrap();
458575

459576
assert_eq!(offer.bytes, buffer.as_slice());
460577
assert_eq!(offer.chains(), vec![ChainHash::using_genesis_block(Network::Bitcoin)]);
@@ -481,6 +598,10 @@ mod tests {
481598
assert_eq!(tlv_stream.issuer, None);
482599
assert_eq!(tlv_stream.quantity_max, None);
483600
assert_eq!(tlv_stream.node_id, Some(&pubkey(42)));
601+
602+
if let Err(e) = Offer::try_from(buffer) {
603+
panic!("error parsing offer: {:?}", e);
604+
}
484605
}
485606

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

0 commit comments

Comments
 (0)