|
9 | 9 |
|
10 | 10 | //! Data structures and encoding for `offer` messages.
|
11 | 11 |
|
| 12 | +use bitcoin::bech32; |
| 13 | +use bitcoin::bech32::FromBase32; |
12 | 14 | use bitcoin::blockdata::constants::genesis_block;
|
13 | 15 | use bitcoin::hash_types::BlockHash;
|
14 | 16 | use bitcoin::network::constants::Network;
|
15 | 17 | use bitcoin::secp256k1::PublicKey;
|
| 18 | +use core::convert::TryFrom; |
16 | 19 | use core::num::NonZeroU64;
|
17 | 20 | use core::ops::{Bound, RangeBounds};
|
| 21 | +use core::str::FromStr; |
18 | 22 | use core::time::Duration;
|
19 | 23 | use ln::features::OfferFeatures;
|
20 |
| -use util::ser::WithLength; |
| 24 | +use ln::msgs::DecodeError; |
| 25 | +use util::ser::{Readable, WithLength}; |
21 | 26 |
|
22 | 27 | use prelude::*;
|
23 | 28 |
|
@@ -245,8 +250,14 @@ impl Offer {
|
245 | 250 |
|
246 | 251 | ///
|
247 | 252 | pub fn node_id(&self) -> PublicKey {
|
248 |
| - self.node_id.unwrap_or_else(|| |
249 |
| - self.paths.as_ref().unwrap().first().unwrap().path.0.last().unwrap().node_id) |
| 253 | + Self::node_id_from_parts(self.node_id, self.paths.as_ref()) |
| 254 | + } |
| 255 | + |
| 256 | + fn node_id_from_parts( |
| 257 | + node_id: Option<PublicKey>, paths: Option<&Vec<BlindedPath>> |
| 258 | + ) -> PublicKey { |
| 259 | + node_id.unwrap_or_else(|| |
| 260 | + paths.unwrap().first().unwrap().path.0.last().unwrap().node_id) |
250 | 261 | }
|
251 | 262 |
|
252 | 263 | ///
|
@@ -352,6 +363,205 @@ impl_writeable!(OnionMessagePath, { node_id, encrypted_recipient_data });
|
352 | 363 |
|
353 | 364 | type Empty = ();
|
354 | 365 |
|
| 366 | +/// An offer parsed from a bech32-encoded string as a TLV stream and the corresponding bytes. The |
| 367 | +/// latter is used for signature verification. |
| 368 | +struct ParsedOffer(OfferTlvStream, Vec<u8>); |
| 369 | + |
| 370 | +/// Error when parsing a bech32 encoded message using [`str::parse`]. |
| 371 | +#[derive(Debug, PartialEq)] |
| 372 | +pub enum ParseError { |
| 373 | + /// The bech32 encoding does not conform to the BOLT 12 requirements for continuing messages |
| 374 | + /// across multiple parts (i.e., '+' followed by whitespace). |
| 375 | + InvalidContinuation, |
| 376 | + /// The bech32 encoding's human-readable part does not match what was expected for the message |
| 377 | + /// being parsed. |
| 378 | + InvalidBech32Hrp, |
| 379 | + /// The string could not be bech32 decoded. |
| 380 | + Bech32(bech32::Error), |
| 381 | + /// The bech32 decoded string could not be decoded as the expected message type. |
| 382 | + Decode(DecodeError), |
| 383 | + /// The parsed message has invalid semantics. |
| 384 | + InvalidSemantics(SemanticError), |
| 385 | +} |
| 386 | + |
| 387 | +#[derive(Debug, PartialEq)] |
| 388 | +/// |
| 389 | +pub enum SemanticError { |
| 390 | + /// |
| 391 | + UnsupportedChain, |
| 392 | + /// |
| 393 | + UnexpectedCurrency, |
| 394 | + /// |
| 395 | + MissingDescription, |
| 396 | + /// |
| 397 | + MissingDestination, |
| 398 | + /// |
| 399 | + MissingPaths, |
| 400 | + /// |
| 401 | + InvalidQuantity, |
| 402 | + /// |
| 403 | + UnexpectedRefund, |
| 404 | + /// |
| 405 | + InvalidSignature(secp256k1::Error), |
| 406 | +} |
| 407 | + |
| 408 | +impl From<bech32::Error> for ParseError { |
| 409 | + fn from(error: bech32::Error) -> Self { |
| 410 | + Self::Bech32(error) |
| 411 | + } |
| 412 | +} |
| 413 | + |
| 414 | +impl From<DecodeError> for ParseError { |
| 415 | + fn from(error: DecodeError) -> Self { |
| 416 | + Self::Decode(error) |
| 417 | + } |
| 418 | +} |
| 419 | + |
| 420 | +impl From<SemanticError> for ParseError { |
| 421 | + fn from(error: SemanticError) -> Self { |
| 422 | + Self::InvalidSemantics(error) |
| 423 | + } |
| 424 | +} |
| 425 | + |
| 426 | +impl From<secp256k1::Error> for SemanticError { |
| 427 | + fn from(error: secp256k1::Error) -> Self { |
| 428 | + Self::InvalidSignature(error) |
| 429 | + } |
| 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 | + Ok(Offer::try_from(ParsedOffer::from_str(s)?)?) |
| 437 | + } |
| 438 | +} |
| 439 | + |
| 440 | +impl TryFrom<ParsedOffer> for Offer { |
| 441 | + type Error = SemanticError; |
| 442 | + |
| 443 | + fn try_from(offer: ParsedOffer) -> Result<Self, Self::Error> { |
| 444 | + let ParsedOffer(OfferTlvStream { |
| 445 | + chains, currency, amount, description, features, absolute_expiry, paths, issuer, |
| 446 | + quantity_min, quantity_max, node_id, send_invoice, refund_for, signature, |
| 447 | + }, data) = offer; |
| 448 | + |
| 449 | + let supported_chains = [ |
| 450 | + genesis_block(Network::Bitcoin).block_hash(), |
| 451 | + genesis_block(Network::Testnet).block_hash(), |
| 452 | + genesis_block(Network::Signet).block_hash(), |
| 453 | + genesis_block(Network::Regtest).block_hash(), |
| 454 | + ]; |
| 455 | + let chains = match chains.map(Into::<Vec<_>>::into) { |
| 456 | + None => None, |
| 457 | + Some(chains) => match chains.first() { |
| 458 | + None => Some(chains), |
| 459 | + Some(chain) if supported_chains.contains(chain) => Some(chains), |
| 460 | + _ => return Err(SemanticError::UnsupportedChain), |
| 461 | + }, |
| 462 | + }; |
| 463 | + |
| 464 | + let amount = match (currency, amount.map(Into::into)) { |
| 465 | + (None, None) => None, |
| 466 | + (None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }), |
| 467 | + (Some(_), None) => return Err(SemanticError::UnexpectedCurrency), |
| 468 | + (Some(iso4217_code), Some(amount)) => Some(Amount::Currency { iso4217_code, amount }), |
| 469 | + }; |
| 470 | + |
| 471 | + let description = match description { |
| 472 | + None => return Err(SemanticError::MissingDescription), |
| 473 | + Some(description) => description.into(), |
| 474 | + }; |
| 475 | + |
| 476 | + let absolute_expiry = absolute_expiry |
| 477 | + .map(Into::into) |
| 478 | + .map(|seconds_from_epoch| Duration::from_secs(seconds_from_epoch)); |
| 479 | + |
| 480 | + let issuer = issuer.map(Into::into); |
| 481 | + |
| 482 | + let (node_id, paths) = match (node_id, paths.map(Into::<Vec<_>>::into)) { |
| 483 | + (None, None) => return Err(SemanticError::MissingDestination), |
| 484 | + (_, Some(paths)) if paths.is_empty() => return Err(SemanticError::MissingPaths), |
| 485 | + (_, paths) => (node_id, paths), |
| 486 | + }; |
| 487 | + |
| 488 | + let quantity_min = quantity_min.map(Into::into); |
| 489 | + let quantity_max = quantity_max.map(Into::into); |
| 490 | + if let Some(quantity_min) = quantity_min { |
| 491 | + if quantity_min < 1 { |
| 492 | + return Err(SemanticError::InvalidQuantity); |
| 493 | + } |
| 494 | + |
| 495 | + if let Some(quantity_max) = quantity_max { |
| 496 | + if quantity_min > quantity_max { |
| 497 | + return Err(SemanticError::InvalidQuantity); |
| 498 | + } |
| 499 | + } |
| 500 | + } |
| 501 | + |
| 502 | + if let Some(quantity_max) = quantity_max { |
| 503 | + if quantity_max < 1 { |
| 504 | + return Err(SemanticError::InvalidQuantity); |
| 505 | + } |
| 506 | + } |
| 507 | + |
| 508 | + let send_invoice = match (send_invoice, refund_for) { |
| 509 | + (None, None) => None, |
| 510 | + (None, Some(_)) => return Err(SemanticError::UnexpectedRefund), |
| 511 | + (Some(_), _) => Some(SendInvoice { refund_for }), |
| 512 | + }; |
| 513 | + |
| 514 | + let id = merkle::root_hash(&data); |
| 515 | + if let Some(signature) = &signature { |
| 516 | + let digest = Offer::message_digest(id); |
| 517 | + let secp_ctx = Secp256k1::verification_only(); |
| 518 | + let pubkey = Offer::node_id_from_parts(node_id, paths.as_ref()); |
| 519 | + secp_ctx.verify_schnorr(signature, &digest, &pubkey.into())?; |
| 520 | + } |
| 521 | + |
| 522 | + Ok(Offer { |
| 523 | + id, chains, amount, description, features, absolute_expiry, issuer, paths, quantity_min, |
| 524 | + quantity_max, node_id, send_invoice, signature, |
| 525 | + }) |
| 526 | + } |
| 527 | +} |
| 528 | + |
| 529 | +const OFFER_BECH32_HRP: &str = "lno"; |
| 530 | + |
| 531 | +impl FromStr for ParsedOffer { |
| 532 | + type Err = ParseError; |
| 533 | + |
| 534 | + fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> { |
| 535 | + // Offer encoding may be split by '+' followed by optional whitespace. |
| 536 | + for chunk in s.split('+') { |
| 537 | + let chunk = chunk.trim_start(); |
| 538 | + if chunk.is_empty() || chunk.contains(char::is_whitespace) { |
| 539 | + return Err(ParseError::InvalidContinuation); |
| 540 | + } |
| 541 | + } |
| 542 | + |
| 543 | + let s = s.chars().filter(|c| *c != '+' && !c.is_whitespace()).collect::<String>(); |
| 544 | + let (hrp, data) = bech32::decode_without_checksum(&s)?; |
| 545 | + |
| 546 | + if hrp != OFFER_BECH32_HRP { |
| 547 | + return Err(ParseError::InvalidBech32Hrp); |
| 548 | + } |
| 549 | + |
| 550 | + let data = Vec::<u8>::from_base32(&data)?; |
| 551 | + Ok(ParsedOffer(Readable::read(&mut &data[..])?, data)) |
| 552 | + } |
| 553 | +} |
| 554 | + |
| 555 | +impl core::fmt::Display for Offer { |
| 556 | + fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { |
| 557 | + use bitcoin::bech32::ToBase32; |
| 558 | + let data = self.to_bytes().to_base32(); |
| 559 | + bech32::encode_without_checksum_to_fmt(f, OFFER_BECH32_HRP, data).expect("HRP is valid").unwrap(); |
| 560 | + |
| 561 | + Ok(()) |
| 562 | + } |
| 563 | +} |
| 564 | + |
355 | 565 | #[cfg(test)]
|
356 | 566 | mod tests {
|
357 | 567 | use super::{Amount, BlindedPath, Destination, OfferBuilder, OnionMessagePath, SendInvoice};
|
@@ -729,3 +939,85 @@ mod tests {
|
729 | 939 | assert_eq!(tlv_stream.send_invoice, Some(&()));
|
730 | 940 | }
|
731 | 941 | }
|
| 942 | + |
| 943 | +#[cfg(test)] |
| 944 | +mod bolt12_tests { |
| 945 | + use super::{Offer, ParseError, ParsedOffer}; |
| 946 | + use bitcoin::bech32; |
| 947 | + use ln::msgs::DecodeError; |
| 948 | + |
| 949 | + #[test] |
| 950 | + fn encodes_offer_as_bech32_without_checksum() { |
| 951 | + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy"; |
| 952 | + let offer = dbg!(encoded_offer.parse::<Offer>().unwrap()); |
| 953 | + let reencoded_offer = offer.to_string(); |
| 954 | + dbg!(reencoded_offer.parse::<Offer>().unwrap()); |
| 955 | + assert_eq!(reencoded_offer, encoded_offer); |
| 956 | + } |
| 957 | + |
| 958 | + #[test] |
| 959 | + fn parses_bech32_encoded_offers() { |
| 960 | + let offers = [ |
| 961 | + // BOLT 12 test vectors |
| 962 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 963 | + "l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 964 | + "l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 965 | + "lno1qcp4256ypqpq+86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qs+y", |
| 966 | + "lno1qcp4256ypqpq+ 86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+ 0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+\nsqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43l+\r\nastpwuh73k29qs+\r y", |
| 967 | + // Two blinded paths |
| 968 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yg06qg2qdd7t628sgykwj5kuc837qmlv9m9gr7sq8ap6erfgacv26nhp8zzcqgzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6heqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9sqs9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 969 | + ]; |
| 970 | + for encoded_offer in &offers { |
| 971 | + // TODO: Use Offer once Destination semantics are finalized. |
| 972 | + if let Err(e) = encoded_offer.parse::<ParsedOffer>() { |
| 973 | + panic!("Invalid offer ({:?}): {}", e, encoded_offer); |
| 974 | + } |
| 975 | + } |
| 976 | + } |
| 977 | + |
| 978 | + #[test] |
| 979 | + fn fails_parsing_bech32_encoded_offers_with_invalid_continuations() { |
| 980 | + let offers = [ |
| 981 | + // BOLT 12 test vectors |
| 982 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+", |
| 983 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+ ", |
| 984 | + "+lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 985 | + "+ lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 986 | + "ln++o1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 987 | + ]; |
| 988 | + for encoded_offer in &offers { |
| 989 | + match encoded_offer.parse::<Offer>() { |
| 990 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 991 | + Err(e) => assert_eq!(e, ParseError::InvalidContinuation), |
| 992 | + } |
| 993 | + } |
| 994 | + |
| 995 | + } |
| 996 | + |
| 997 | + #[test] |
| 998 | + fn fails_parsing_bech32_encoded_offer_with_invalid_hrp() { |
| 999 | + let encoded_offer = "lni1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy"; |
| 1000 | + match encoded_offer.parse::<Offer>() { |
| 1001 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1002 | + Err(e) => assert_eq!(e, ParseError::InvalidBech32Hrp), |
| 1003 | + } |
| 1004 | + } |
| 1005 | + |
| 1006 | + #[test] |
| 1007 | + fn fails_parsing_bech32_encoded_offer_with_invalid_bech32_data() { |
| 1008 | + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qso"; |
| 1009 | + match encoded_offer.parse::<Offer>() { |
| 1010 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1011 | + Err(e) => assert_eq!(e, ParseError::Bech32(bech32::Error::InvalidChar('o'))), |
| 1012 | + } |
| 1013 | + } |
| 1014 | + |
| 1015 | + #[test] |
| 1016 | + fn fails_parsing_bech32_encoded_offer_with_invalid_tlv_data() { |
| 1017 | + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsyqqqqq"; |
| 1018 | + match encoded_offer.parse::<Offer>() { |
| 1019 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1020 | + Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)), |
| 1021 | + } |
| 1022 | + } |
| 1023 | +} |
0 commit comments