|
22 | 22 | //! use core::time::Duration;
|
23 | 23 | //!
|
24 | 24 | //! 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; |
26 | 27 | //!
|
27 |
| -//! # use bitcoin::secp256k1; |
28 | 28 | //! # use lightning::onion_message::BlindedPath;
|
29 | 29 | //! # #[cfg(feature = "std")]
|
30 | 30 | //! # use std::time::SystemTime;
|
|
33 | 33 | //! # fn create_another_blinded_path() -> BlindedPath { unimplemented!() }
|
34 | 34 | //! #
|
35 | 35 | //! # #[cfg(feature = "std")]
|
36 |
| -//! # fn build() -> Result<(), secp256k1::Error> { |
| 36 | +//! # fn build() -> Result<(), ParseError> { |
37 | 37 | //! 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()); |
39 | 39 | //! let pubkey = PublicKey::from(keys);
|
40 | 40 | //!
|
41 | 41 | //! let one_item = NonZeroU64::new(1).unwrap();
|
|
49 | 49 | //! .path(create_another_blinded_path())
|
50 | 50 | //! .build()
|
51 | 51 | //! .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>()?; |
52 | 58 | //! # Ok(())
|
53 | 59 | //! # }
|
54 | 60 | //! ```
|
55 | 61 |
|
56 | 62 | use bitcoin::blockdata::constants::ChainHash;
|
57 | 63 | use bitcoin::network::constants::Network;
|
58 | 64 | use bitcoin::secp256k1::PublicKey;
|
| 65 | +use core::convert::TryFrom; |
59 | 66 | use core::num::NonZeroU64;
|
60 | 67 | use core::ops::{Bound, RangeBounds};
|
| 68 | +use core::str::FromStr; |
61 | 69 | use core::time::Duration;
|
62 | 70 | use io;
|
63 | 71 | use ln::features::OfferFeatures;
|
| 72 | +use offers::parse::{Bech32Encode, ParseError, SemanticError}; |
64 | 73 | use onion_message::BlindedPath;
|
65 | 74 | use util::ser::{Writeable, Writer};
|
66 | 75 |
|
@@ -333,6 +342,12 @@ impl Offer {
|
333 | 342 | }
|
334 | 343 | }
|
335 | 344 |
|
| 345 | +impl AsRef<[u8]> for Offer { |
| 346 | + fn as_ref(&self) -> &[u8] { |
| 347 | + &self.bytes |
| 348 | + } |
| 349 | +} |
| 350 | + |
336 | 351 | impl OfferContents {
|
337 | 352 | pub fn quantity_min(&self) -> u64 {
|
338 | 353 | self.quantity_min.unwrap_or(1)
|
@@ -411,6 +426,101 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, {
|
411 | 426 | (24, node_id: PublicKey),
|
412 | 427 | });
|
413 | 428 |
|
| 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 | + |
414 | 524 | #[cfg(test)]
|
415 | 525 | mod tests {
|
416 | 526 | use super::{Amount, OfferBuilder};
|
@@ -781,3 +891,88 @@ mod tests {
|
781 | 891 | );
|
782 | 892 | }
|
783 | 893 | }
|
| 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 | +} |
0 commit comments