Skip to content

Commit 90a05ff

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 4e4b15f commit 90a05ff

File tree

4 files changed

+329
-20
lines changed

4 files changed

+329
-20
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: 226 additions & 19 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::{Amount, OfferBuilder, Quantity};
26+
//! use lightning::offers::offer::{Amount, 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::*;
@@ -172,11 +190,9 @@ impl OfferBuilder {
172190

173191
/// Builds an [`Offer`] from the builder's settings.
174192
pub fn build(self) -> Result<Offer, ()> {
193+
// TODO: Also check for Amount::Currency
175194
if let Some(Amount::Currency { .. }) = self.offer.amount {
176-
return Err(());
177-
}
178-
179-
if self.offer.amount_msats() > MAX_VALUE_MSAT {
195+
} else if self.offer.amount_msats() > MAX_VALUE_MSAT {
180196
return Err(());
181197
}
182198

@@ -305,6 +321,12 @@ impl Offer {
305321
}
306322
}
307323

324+
impl AsRef<[u8]> for Offer {
325+
fn as_ref(&self) -> &[u8] {
326+
&self.bytes
327+
}
328+
}
329+
308330
impl OfferContents {
309331
pub fn amount_msats(&self) -> u64 {
310332
self.amount.as_ref().map(Amount::as_msats).unwrap_or(0)
@@ -343,12 +365,27 @@ impl OfferContents {
343365
}
344366
}
345367

368+
impl Writeable for Offer {
369+
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
370+
WithoutLength(&self.bytes).write(writer)
371+
}
372+
}
373+
346374
impl Writeable for OfferContents {
347375
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
348376
self.as_tlv_stream().write(writer)
349377
}
350378
}
351379

380+
impl TryFrom<Vec<u8>> for Offer {
381+
type Error = ParseError;
382+
383+
fn try_from(bytes: Vec<u8>) -> Result<Self, Self::Error> {
384+
let tlv_stream: OfferTlvStream = Readable::read(&mut &bytes[..])?;
385+
Offer::try_from((bytes, tlv_stream))
386+
}
387+
}
388+
352389
/// The minimum amount required for an item in an [`Offer`], denominated in either bitcoin or
353390
/// another currency.
354391
#[derive(Clone, Debug, PartialEq)]
@@ -392,6 +429,14 @@ pub enum Quantity {
392429
}
393430

394431
impl Quantity {
432+
fn new(quantity: Option<u64>) -> Self {
433+
match quantity {
434+
Some(1) | None => Quantity::One,
435+
Some(0) => Quantity::Many,
436+
Some(n) => Quantity::Max(NonZeroU64::new(n).unwrap()),
437+
}
438+
}
439+
395440
fn to_tlv_record(&self) -> Option<u64> {
396441
match self {
397442
Quantity::One => None,
@@ -415,13 +460,88 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, {
415460
(22, node_id: PublicKey),
416461
});
417462

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

422541
use bitcoin::blockdata::constants::ChainHash;
423542
use bitcoin::network::constants::Network;
424543
use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
544+
use core::convert::TryFrom;
425545
use core::num::NonZeroU64;
426546
use core::time::Duration;
427547
use crate::ln::features::OfferFeatures;
@@ -444,7 +564,7 @@ mod tests {
444564
let offer = OfferBuilder::new("foo".into(), pubkey(42)).build().unwrap();
445565
let tlv_stream = offer.as_tlv_stream();
446566
let mut buffer = Vec::new();
447-
offer.contents.write(&mut buffer).unwrap();
567+
offer.write(&mut buffer).unwrap();
448568

449569
assert_eq!(offer.bytes, buffer.as_slice());
450570
assert_eq!(offer.chains(), vec![ChainHash::using_genesis_block(Network::Bitcoin)]);
@@ -471,6 +591,10 @@ mod tests {
471591
assert_eq!(tlv_stream.issuer, None);
472592
assert_eq!(tlv_stream.quantity_max, None);
473593
assert_eq!(tlv_stream.node_id, Some(&pubkey(42)));
594+
595+
if let Err(e) = Offer::try_from(buffer) {
596+
panic!("error parsing offer: {:?}", e);
597+
}
474598
}
475599

476600
#[test]
@@ -537,16 +661,14 @@ mod tests {
537661
assert_eq!(tlv_stream.amount, Some(1000));
538662
assert_eq!(tlv_stream.currency, None);
539663

540-
let builder = OfferBuilder::new("foo".into(), pubkey(42))
541-
.amount(currency_amount.clone());
542-
let tlv_stream = builder.offer.as_tlv_stream();
543-
assert_eq!(builder.offer.amount.as_ref(), Some(&currency_amount));
664+
let offer = OfferBuilder::new("foo".into(), pubkey(42))
665+
.amount(currency_amount.clone())
666+
.build()
667+
.unwrap();
668+
let tlv_stream = offer.as_tlv_stream();
669+
assert_eq!(offer.amount(), Some(&currency_amount));
544670
assert_eq!(tlv_stream.amount, Some(10));
545671
assert_eq!(tlv_stream.currency, Some(b"USD"));
546-
match builder.build() {
547-
Ok(_) => panic!("expected error"),
548-
Err(e) => assert_eq!(e, ()),
549-
}
550672

551673
let offer = OfferBuilder::new("foo".into(), pubkey(42))
552674
.amount(currency_amount.clone())
@@ -707,3 +829,88 @@ mod tests {
707829

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

0 commit comments

Comments
 (0)