Skip to content

Commit 47e4924

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 3c999b9 commit 47e4924

File tree

4 files changed

+309
-5
lines changed

4 files changed

+309
-5
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: 199 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
//! use core::time::Duration;
2323
//!
2424
//! use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey};
25-
//! use lightning::offers::offer::{Amount, OfferBuilder};
25+
//! use lightning::offers::offer::{Amount, Offer, OfferBuilder};
26+
//! use lightning::offers::parse::ParseError;
2627
//!
27-
//! # use bitcoin::secp256k1;
2828
//! # use lightning::onion_message::BlindedPath;
2929
//! # #[cfg(feature = "std")]
3030
//! # use std::time::SystemTime;
@@ -33,9 +33,9 @@
3333
//! # fn create_another_blinded_path() -> BlindedPath { unimplemented!() }
3434
//! #
3535
//! # #[cfg(feature = "std")]
36-
//! # fn build() -> Result<(), secp256k1::Error> {
36+
//! # fn build() -> Result<(), ParseError> {
3737
//! let secp_ctx = Secp256k1::new();
38-
//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32])?);
38+
//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap());
3939
//! let pubkey = PublicKey::from(keys);
4040
//!
4141
//! let one_item = NonZeroU64::new(1).unwrap();
@@ -49,18 +49,27 @@
4949
//! .path(create_another_blinded_path())
5050
//! .build()
5151
//! .unwrap();
52+
//!
53+
//! // Encode as a bech32 string for use in a QR code.
54+
//! let encoded_offer = offer.to_string();
55+
//!
56+
//! // Parse from a bech32 string after scanning from a QR code.
57+
//! let offer = encoded_offer.parse::<Offer>()?;
5258
//! # Ok(())
5359
//! # }
5460
//! ```
5561
5662
use bitcoin::blockdata::constants::ChainHash;
5763
use bitcoin::network::constants::Network;
5864
use bitcoin::secp256k1::PublicKey;
65+
use core::convert::TryFrom;
5966
use core::num::NonZeroU64;
6067
use core::ops::{Bound, RangeBounds};
68+
use core::str::FromStr;
6169
use core::time::Duration;
6270
use io;
6371
use ln::features::OfferFeatures;
72+
use offers::parse::{Bech32Encode, ParseError, SemanticError};
6473
use onion_message::BlindedPath;
6574
use util::ser::{Writeable, Writer};
6675

@@ -333,6 +342,12 @@ impl Offer {
333342
}
334343
}
335344

345+
impl AsRef<[u8]> for Offer {
346+
fn as_ref(&self) -> &[u8] {
347+
&self.bytes
348+
}
349+
}
350+
336351
impl OfferContents {
337352
pub fn quantity_min(&self) -> u64 {
338353
self.quantity_min.unwrap_or(1)
@@ -411,6 +426,101 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, {
411426
(24, node_id: PublicKey),
412427
});
413428

429+
impl Bech32Encode for Offer {
430+
type TlvStream = OfferTlvStream;
431+
432+
const BECH32_HRP: &'static str = "lno";
433+
}
434+
435+
impl FromStr for Offer {
436+
type Err = ParseError;
437+
438+
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
439+
let (tlv_stream, bytes) = Offer::from_bech32_str(s)?;
440+
let contents = OfferContents::try_from(tlv_stream)?;
441+
Ok(Offer { bytes, contents })
442+
}
443+
}
444+
445+
impl TryFrom<OfferTlvStream> for OfferContents {
446+
type Error = SemanticError;
447+
448+
fn try_from(tlv_stream: OfferTlvStream) -> Result<Self, Self::Error> {
449+
let OfferTlvStream {
450+
chains, metadata, currency, amount, description, features, absolute_expiry, paths,
451+
issuer, quantity_min, quantity_max, node_id,
452+
} = tlv_stream;
453+
454+
let supported_chains = [
455+
ChainHash::using_genesis_block(Network::Bitcoin),
456+
ChainHash::using_genesis_block(Network::Testnet),
457+
ChainHash::using_genesis_block(Network::Signet),
458+
ChainHash::using_genesis_block(Network::Regtest),
459+
];
460+
let chains = match chains {
461+
None => None,
462+
Some(chains) => match chains.first() {
463+
None => Some(chains),
464+
Some(chain) if supported_chains.contains(chain) => Some(chains),
465+
_ => return Err(SemanticError::UnsupportedChain),
466+
},
467+
};
468+
469+
let amount = match (currency, amount) {
470+
(None, None) => None,
471+
(None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }),
472+
(Some(_), None) => return Err(SemanticError::UnexpectedCurrency),
473+
(Some(iso4217_code), Some(amount)) => Some(Amount::Currency { iso4217_code, amount }),
474+
};
475+
476+
let description = match description {
477+
None => return Err(SemanticError::MissingDescription),
478+
Some(description) => description,
479+
};
480+
481+
let absolute_expiry = absolute_expiry
482+
.map(|seconds_from_epoch| Duration::from_secs(seconds_from_epoch));
483+
484+
let paths = match paths {
485+
Some(paths) if paths.is_empty() => return Err(SemanticError::MissingPaths),
486+
paths => paths,
487+
};
488+
489+
if let Some(quantity_min) = quantity_min {
490+
if quantity_min < 1 {
491+
return Err(SemanticError::InvalidQuantity);
492+
}
493+
494+
if let Some(quantity_max) = quantity_max {
495+
if quantity_min > quantity_max {
496+
return Err(SemanticError::InvalidQuantity);
497+
}
498+
}
499+
}
500+
501+
if let Some(quantity_max) = quantity_max {
502+
if quantity_max < 1 {
503+
return Err(SemanticError::InvalidQuantity);
504+
}
505+
}
506+
507+
if node_id.is_none() {
508+
return Err(SemanticError::MissingNodeId);
509+
}
510+
511+
Ok(OfferContents {
512+
chains, metadata, amount, description, features, absolute_expiry, issuer, paths,
513+
quantity_min, quantity_max, signing_pubkey: node_id,
514+
})
515+
}
516+
}
517+
518+
impl core::fmt::Display for Offer {
519+
fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
520+
self.fmt_bech32_str(f)
521+
}
522+
}
523+
414524
#[cfg(test)]
415525
mod tests {
416526
use super::{Amount, OfferBuilder};
@@ -781,3 +891,88 @@ mod tests {
781891
);
782892
}
783893
}
894+
895+
#[cfg(test)]
896+
mod bolt12_tests {
897+
use super::{Offer, ParseError};
898+
use bitcoin::bech32;
899+
use ln::msgs::DecodeError;
900+
901+
// TODO: Remove once test vectors are updated.
902+
#[ignore]
903+
#[test]
904+
fn encodes_offer_as_bech32_without_checksum() {
905+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy";
906+
let offer = dbg!(encoded_offer.parse::<Offer>().unwrap());
907+
let reencoded_offer = offer.to_string();
908+
dbg!(reencoded_offer.parse::<Offer>().unwrap());
909+
assert_eq!(reencoded_offer, encoded_offer);
910+
}
911+
912+
// TODO: Remove once test vectors are updated.
913+
#[ignore]
914+
#[test]
915+
fn parses_bech32_encoded_offers() {
916+
let offers = [
917+
// BOLT 12 test vectors
918+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
919+
"l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
920+
"l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
921+
"lno1qcp4256ypqpq+86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qs+y",
922+
"lno1qcp4256ypqpq+ 86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+ 0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+\nsqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43l+\r\nastpwuh73k29qs+\r y",
923+
// Two blinded paths
924+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yg06qg2qdd7t628sgykwj5kuc837qmlv9m9gr7sq8ap6erfgacv26nhp8zzcqgzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6heqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9sqs9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
925+
];
926+
for encoded_offer in &offers {
927+
if let Err(e) = encoded_offer.parse::<Offer>() {
928+
panic!("Invalid offer ({:?}): {}", e, encoded_offer);
929+
}
930+
}
931+
}
932+
933+
#[test]
934+
fn fails_parsing_bech32_encoded_offers_with_invalid_continuations() {
935+
let offers = [
936+
// BOLT 12 test vectors
937+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+",
938+
"lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+ ",
939+
"+lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
940+
"+ lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
941+
"ln++o1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy",
942+
];
943+
for encoded_offer in &offers {
944+
match encoded_offer.parse::<Offer>() {
945+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
946+
Err(e) => assert_eq!(e, ParseError::InvalidContinuation),
947+
}
948+
}
949+
950+
}
951+
952+
#[test]
953+
fn fails_parsing_bech32_encoded_offer_with_invalid_hrp() {
954+
let encoded_offer = "lni1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy";
955+
match encoded_offer.parse::<Offer>() {
956+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
957+
Err(e) => assert_eq!(e, ParseError::InvalidBech32Hrp),
958+
}
959+
}
960+
961+
#[test]
962+
fn fails_parsing_bech32_encoded_offer_with_invalid_bech32_data() {
963+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qso";
964+
match encoded_offer.parse::<Offer>() {
965+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
966+
Err(e) => assert_eq!(e, ParseError::Bech32(bech32::Error::InvalidChar('o'))),
967+
}
968+
}
969+
970+
#[test]
971+
fn fails_parsing_bech32_encoded_offer_with_invalid_tlv_data() {
972+
let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsyqqqqq";
973+
match encoded_offer.parse::<Offer>() {
974+
Ok(_) => panic!("Valid offer: {}", encoded_offer),
975+
Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)),
976+
}
977+
}
978+
}

lightning/src/offers/parse.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// This file is Copyright its original authors, visible in version control
2+
// history.
3+
//
4+
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5+
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6+
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7+
// You may not use this file except in accordance with one or both of these
8+
// licenses.
9+
10+
//! Parsing and formatting for bech32 message encoding.
11+
12+
use bitcoin::bech32;
13+
use bitcoin::bech32::{FromBase32, ToBase32};
14+
use core::fmt;
15+
use ln::msgs::DecodeError;
16+
use util::ser::Readable;
17+
18+
use prelude::*;
19+
20+
/// Indicates a message can be encoded using bech32.
21+
pub(crate) trait Bech32Encode: AsRef<[u8]> {
22+
/// TLV stream that a bech32-encoded message is parsed into.
23+
type TlvStream: Readable;
24+
25+
/// Human readable part of the message's bech32 encoding.
26+
const BECH32_HRP: &'static str;
27+
28+
/// Parses a bech32-encoded message into a TLV stream.
29+
fn from_bech32_str(s: &str) -> Result<(Self::TlvStream, Vec<u8>), ParseError> {
30+
// Offer encoding may be split by '+' followed by optional whitespace.
31+
for chunk in s.split('+') {
32+
let chunk = chunk.trim_start();
33+
if chunk.is_empty() || chunk.contains(char::is_whitespace) {
34+
return Err(ParseError::InvalidContinuation);
35+
}
36+
}
37+
38+
let s = s.chars().filter(|c| *c != '+' && !c.is_whitespace()).collect::<String>();
39+
let (hrp, data) = bech32::decode_without_checksum(&s)?;
40+
41+
if hrp != Self::BECH32_HRP {
42+
return Err(ParseError::InvalidBech32Hrp);
43+
}
44+
45+
let data = Vec::<u8>::from_base32(&data)?;
46+
Ok((Readable::read(&mut &data[..])?, data))
47+
}
48+
49+
/// Formats the message using bech32-encoding.
50+
fn fmt_bech32_str(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
51+
bech32::encode_without_checksum_to_fmt(f, Self::BECH32_HRP, self.as_ref().to_base32())
52+
.expect("HRP is valid").unwrap();
53+
54+
Ok(())
55+
}
56+
}
57+
58+
/// Error when parsing a bech32 encoded message using [`str::parse`].
59+
#[derive(Debug, PartialEq)]
60+
pub enum ParseError {
61+
/// The bech32 encoding does not conform to the BOLT 12 requirements for continuing messages
62+
/// across multiple parts (i.e., '+' followed by whitespace).
63+
InvalidContinuation,
64+
/// The bech32 encoding's human-readable part does not match what was expected for the message
65+
/// being parsed.
66+
InvalidBech32Hrp,
67+
/// The string could not be bech32 decoded.
68+
Bech32(bech32::Error),
69+
/// The bech32 decoded string could not be decoded as the expected message type.
70+
Decode(DecodeError),
71+
/// The parsed message has invalid semantics.
72+
InvalidSemantics(SemanticError),
73+
}
74+
75+
/// Error when interpreting a TLV stream as a specific type.
76+
#[derive(Debug, PartialEq)]
77+
pub enum SemanticError {
78+
/// The provided block hash does not correspond to a supported chain.
79+
UnsupportedChain,
80+
/// A currency was provided without an amount.
81+
UnexpectedCurrency,
82+
/// A required description was not provided.
83+
MissingDescription,
84+
/// A node id was not provided.
85+
MissingNodeId,
86+
/// An empty set of blinded paths was provided.
87+
MissingPaths,
88+
/// A quantity representing an empty range or that was outside of a valid range was provided.
89+
InvalidQuantity,
90+
}
91+
92+
impl From<bech32::Error> for ParseError {
93+
fn from(error: bech32::Error) -> Self {
94+
Self::Bech32(error)
95+
}
96+
}
97+
98+
impl From<DecodeError> for ParseError {
99+
fn from(error: DecodeError) -> Self {
100+
Self::Decode(error)
101+
}
102+
}
103+
104+
impl From<SemanticError> for ParseError {
105+
fn from(error: SemanticError) -> Self {
106+
Self::InvalidSemantics(error)
107+
}
108+
}

lightning/src/util/ser_macros.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ macro_rules! tlv_stream {
465465
$(($type:expr, $field:ident : $fieldty:ident$(<$gen:ident>)?)),* $(,)*
466466
}) => {
467467
#[derive(Debug)]
468-
struct $name {
468+
pub(crate) struct $name {
469469
$(
470470
$field: Option<$fieldty$(<$gen>)?>,
471471
)*

0 commit comments

Comments
 (0)