Skip to content

Commit 751cbd6

Browse files
committed
WIP: Offer parsing
1 parent 248b480 commit 751cbd6

File tree

4 files changed

+302
-1
lines changed

4 files changed

+302
-1
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: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,14 @@
5454
use bitcoin::blockdata::constants::ChainHash;
5555
use bitcoin::network::constants::Network;
5656
use bitcoin::secp256k1::PublicKey;
57+
use core::convert::TryFrom;
5758
use core::num::NonZeroU64;
5859
use core::ops::{Bound, RangeBounds};
60+
use core::str::FromStr;
5961
use core::time::Duration;
6062
use io;
6163
use ln::features::OfferFeatures;
64+
use offers::parse::{Bech32Encode, ParseError, SemanticError};
6265
use onion_message::BlindedPath;
6366
use util::ser::{Writeable, Writer};
6467

@@ -336,6 +339,12 @@ impl Offer {
336339
}
337340
}
338341

342+
impl AsRef<[u8]> for Offer {
343+
fn as_ref(&self) -> &[u8] {
344+
&self.bytes
345+
}
346+
}
347+
339348
impl OfferContents {
340349
pub fn quantity_min(&self) -> u64 {
341350
self.quantity_min.unwrap_or(1)
@@ -414,6 +423,108 @@ tlv_stream!(struct OfferTlvStream {
414423
(24, node_id: PublicKey),
415424
});
416425

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

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
@@ -438,7 +438,7 @@ macro_rules! tlv_stream {
438438
$(($type:expr, $field:ident : $fieldty:ident$(<$gen:ident>)?)),* $(,)*
439439
}) => {
440440
#[derive(Debug)]
441-
struct $name {
441+
pub(crate) struct $name {
442442
$(
443443
$field: Option<tlv_record_type!($fieldty$(<$gen>)?)>,
444444
)*

0 commit comments

Comments
 (0)