|
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::hashes::{Hash, sha256};
|
15 | 17 | use bitcoin::network::constants::Network;
|
16 | 18 | use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, self};
|
17 | 19 | use bitcoin::secp256k1::schnorr::Signature;
|
| 20 | +use core::convert::TryFrom; |
18 | 21 | use core::num::NonZeroU64;
|
19 | 22 | use core::ops::{Bound, RangeBounds};
|
| 23 | +use core::str::FromStr; |
20 | 24 | use core::time::Duration;
|
21 | 25 | use ln::PaymentHash;
|
22 | 26 | use ln::features::OfferFeatures;
|
23 |
| -use util::ser::WithLength; |
| 27 | +use ln::msgs::DecodeError; |
| 28 | +use util::ser::{Readable, WithLength}; |
24 | 29 |
|
25 | 30 | use prelude::*;
|
26 | 31 | use super::merkle;
|
@@ -250,8 +255,14 @@ impl Offer {
|
250 | 255 |
|
251 | 256 | ///
|
252 | 257 | pub fn node_id(&self) -> PublicKey {
|
253 |
| - self.node_id.unwrap_or_else(|| |
254 |
| - self.paths.as_ref().unwrap().first().unwrap().path.0.last().unwrap().node_id) |
| 258 | + Self::node_id_from_parts(self.node_id, self.paths.as_ref()) |
| 259 | + } |
| 260 | + |
| 261 | + fn node_id_from_parts( |
| 262 | + node_id: Option<PublicKey>, paths: Option<&Vec<BlindedPath>> |
| 263 | + ) -> PublicKey { |
| 264 | + node_id.unwrap_or_else(|| |
| 265 | + paths.unwrap().first().unwrap().path.0.last().unwrap().node_id) |
255 | 266 | }
|
256 | 267 |
|
257 | 268 | ///
|
@@ -388,6 +399,205 @@ impl_writeable!(OnionMessagePath, { node_id, encrypted_recipient_data });
|
388 | 399 |
|
389 | 400 | type Empty = ();
|
390 | 401 |
|
| 402 | +/// An offer parsed from a bech32-encoded string as a TLV stream and the corresponding bytes. The |
| 403 | +/// latter is used for signature verification. |
| 404 | +struct ParsedOffer(OfferTlvStream, Vec<u8>); |
| 405 | + |
| 406 | +/// Error when parsing a bech32 encoded message using [`str::parse`]. |
| 407 | +#[derive(Debug, PartialEq)] |
| 408 | +pub enum ParseError { |
| 409 | + /// The bech32 encoding does not conform to the BOLT 12 requirements for continuing messages |
| 410 | + /// across multiple parts (i.e., '+' followed by whitespace). |
| 411 | + InvalidContinuation, |
| 412 | + /// The bech32 encoding's human-readable part does not match what was expected for the message |
| 413 | + /// being parsed. |
| 414 | + InvalidBech32Hrp, |
| 415 | + /// The string could not be bech32 decoded. |
| 416 | + Bech32(bech32::Error), |
| 417 | + /// The bech32 decoded string could not be decoded as the expected message type. |
| 418 | + Decode(DecodeError), |
| 419 | + /// The parsed message has invalid semantics. |
| 420 | + InvalidSemantics(SemanticError), |
| 421 | +} |
| 422 | + |
| 423 | +#[derive(Debug, PartialEq)] |
| 424 | +/// |
| 425 | +pub enum SemanticError { |
| 426 | + /// |
| 427 | + UnsupportedChain, |
| 428 | + /// |
| 429 | + UnexpectedCurrency, |
| 430 | + /// |
| 431 | + MissingDescription, |
| 432 | + /// |
| 433 | + MissingDestination, |
| 434 | + /// |
| 435 | + MissingPaths, |
| 436 | + /// |
| 437 | + InvalidQuantity, |
| 438 | + /// |
| 439 | + UnexpectedRefund, |
| 440 | + /// |
| 441 | + InvalidSignature(secp256k1::Error), |
| 442 | +} |
| 443 | + |
| 444 | +impl From<bech32::Error> for ParseError { |
| 445 | + fn from(error: bech32::Error) -> Self { |
| 446 | + Self::Bech32(error) |
| 447 | + } |
| 448 | +} |
| 449 | + |
| 450 | +impl From<DecodeError> for ParseError { |
| 451 | + fn from(error: DecodeError) -> Self { |
| 452 | + Self::Decode(error) |
| 453 | + } |
| 454 | +} |
| 455 | + |
| 456 | +impl From<SemanticError> for ParseError { |
| 457 | + fn from(error: SemanticError) -> Self { |
| 458 | + Self::InvalidSemantics(error) |
| 459 | + } |
| 460 | +} |
| 461 | + |
| 462 | +impl From<secp256k1::Error> for SemanticError { |
| 463 | + fn from(error: secp256k1::Error) -> Self { |
| 464 | + Self::InvalidSignature(error) |
| 465 | + } |
| 466 | +} |
| 467 | + |
| 468 | +impl FromStr for Offer { |
| 469 | + type Err = ParseError; |
| 470 | + |
| 471 | + fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> { |
| 472 | + Ok(Offer::try_from(ParsedOffer::from_str(s)?)?) |
| 473 | + } |
| 474 | +} |
| 475 | + |
| 476 | +impl TryFrom<ParsedOffer> for Offer { |
| 477 | + type Error = SemanticError; |
| 478 | + |
| 479 | + fn try_from(offer: ParsedOffer) -> Result<Self, Self::Error> { |
| 480 | + let ParsedOffer(OfferTlvStream { |
| 481 | + chains, currency, amount, description, features, absolute_expiry, paths, issuer, |
| 482 | + quantity_min, quantity_max, node_id, send_invoice, refund_for, signature, |
| 483 | + }, data) = offer; |
| 484 | + |
| 485 | + let supported_chains = [ |
| 486 | + genesis_block(Network::Bitcoin).block_hash(), |
| 487 | + genesis_block(Network::Testnet).block_hash(), |
| 488 | + genesis_block(Network::Signet).block_hash(), |
| 489 | + genesis_block(Network::Regtest).block_hash(), |
| 490 | + ]; |
| 491 | + let chains = match chains.map(Into::<Vec<_>>::into) { |
| 492 | + None => None, |
| 493 | + Some(chains) => match chains.first() { |
| 494 | + None => Some(chains), |
| 495 | + Some(chain) if supported_chains.contains(chain) => Some(chains), |
| 496 | + _ => return Err(SemanticError::UnsupportedChain), |
| 497 | + }, |
| 498 | + }; |
| 499 | + |
| 500 | + let amount = match (currency, amount.map(Into::into)) { |
| 501 | + (None, None) => None, |
| 502 | + (None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }), |
| 503 | + (Some(_), None) => return Err(SemanticError::UnexpectedCurrency), |
| 504 | + (Some(iso4217_code), Some(amount)) => Some(Amount::Currency { iso4217_code, amount }), |
| 505 | + }; |
| 506 | + |
| 507 | + let description = match description { |
| 508 | + None => return Err(SemanticError::MissingDescription), |
| 509 | + Some(description) => description.into(), |
| 510 | + }; |
| 511 | + |
| 512 | + let absolute_expiry = absolute_expiry |
| 513 | + .map(Into::into) |
| 514 | + .map(|seconds_from_epoch| Duration::from_secs(seconds_from_epoch)); |
| 515 | + |
| 516 | + let issuer = issuer.map(Into::into); |
| 517 | + |
| 518 | + let (node_id, paths) = match (node_id, paths.map(Into::<Vec<_>>::into)) { |
| 519 | + (None, None) => return Err(SemanticError::MissingDestination), |
| 520 | + (_, Some(paths)) if paths.is_empty() => return Err(SemanticError::MissingPaths), |
| 521 | + (_, paths) => (node_id, paths), |
| 522 | + }; |
| 523 | + |
| 524 | + let quantity_min = quantity_min.map(Into::into); |
| 525 | + let quantity_max = quantity_max.map(Into::into); |
| 526 | + if let Some(quantity_min) = quantity_min { |
| 527 | + if quantity_min < 1 { |
| 528 | + return Err(SemanticError::InvalidQuantity); |
| 529 | + } |
| 530 | + |
| 531 | + if let Some(quantity_max) = quantity_max { |
| 532 | + if quantity_min > quantity_max { |
| 533 | + return Err(SemanticError::InvalidQuantity); |
| 534 | + } |
| 535 | + } |
| 536 | + } |
| 537 | + |
| 538 | + if let Some(quantity_max) = quantity_max { |
| 539 | + if quantity_max < 1 { |
| 540 | + return Err(SemanticError::InvalidQuantity); |
| 541 | + } |
| 542 | + } |
| 543 | + |
| 544 | + let send_invoice = match (send_invoice, refund_for) { |
| 545 | + (None, None) => None, |
| 546 | + (None, Some(_)) => return Err(SemanticError::UnexpectedRefund), |
| 547 | + (Some(_), _) => Some(SendInvoice { refund_for }), |
| 548 | + }; |
| 549 | + |
| 550 | + let id = merkle::root_hash(&data); |
| 551 | + if let Some(signature) = &signature { |
| 552 | + let digest = Offer::message_digest(id); |
| 553 | + let secp_ctx = Secp256k1::verification_only(); |
| 554 | + let pubkey = Offer::node_id_from_parts(node_id, paths.as_ref()); |
| 555 | + secp_ctx.verify_schnorr(signature, &digest, &pubkey.into())?; |
| 556 | + } |
| 557 | + |
| 558 | + Ok(Offer { |
| 559 | + id, chains, amount, description, features, absolute_expiry, issuer, paths, quantity_min, |
| 560 | + quantity_max, node_id, send_invoice, signature, |
| 561 | + }) |
| 562 | + } |
| 563 | +} |
| 564 | + |
| 565 | +const OFFER_BECH32_HRP: &str = "lno"; |
| 566 | + |
| 567 | +impl FromStr for ParsedOffer { |
| 568 | + type Err = ParseError; |
| 569 | + |
| 570 | + fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> { |
| 571 | + // Offer encoding may be split by '+' followed by optional whitespace. |
| 572 | + for chunk in s.split('+') { |
| 573 | + let chunk = chunk.trim_start(); |
| 574 | + if chunk.is_empty() || chunk.contains(char::is_whitespace) { |
| 575 | + return Err(ParseError::InvalidContinuation); |
| 576 | + } |
| 577 | + } |
| 578 | + |
| 579 | + let s = s.chars().filter(|c| *c != '+' && !c.is_whitespace()).collect::<String>(); |
| 580 | + let (hrp, data) = bech32::decode_without_checksum(&s)?; |
| 581 | + |
| 582 | + if hrp != OFFER_BECH32_HRP { |
| 583 | + return Err(ParseError::InvalidBech32Hrp); |
| 584 | + } |
| 585 | + |
| 586 | + let data = Vec::<u8>::from_base32(&data)?; |
| 587 | + Ok(ParsedOffer(Readable::read(&mut &data[..])?, data)) |
| 588 | + } |
| 589 | +} |
| 590 | + |
| 591 | +impl core::fmt::Display for Offer { |
| 592 | + fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { |
| 593 | + use bitcoin::bech32::ToBase32; |
| 594 | + let data = self.to_bytes().to_base32(); |
| 595 | + bech32::encode_without_checksum_to_fmt(f, OFFER_BECH32_HRP, data).expect("HRP is valid").unwrap(); |
| 596 | + |
| 597 | + Ok(()) |
| 598 | + } |
| 599 | +} |
| 600 | + |
391 | 601 | #[cfg(test)]
|
392 | 602 | mod tests {
|
393 | 603 | use super::{Amount, BlindedPath, Destination, OfferBuilder, OnionMessagePath, SendInvoice, merkle};
|
@@ -790,3 +1000,85 @@ mod tests {
|
790 | 1000 | assert_eq!(tlv_stream.send_invoice, Some(&()));
|
791 | 1001 | }
|
792 | 1002 | }
|
| 1003 | + |
| 1004 | +#[cfg(test)] |
| 1005 | +mod bolt12_tests { |
| 1006 | + use super::{Offer, ParseError, ParsedOffer}; |
| 1007 | + use bitcoin::bech32; |
| 1008 | + use ln::msgs::DecodeError; |
| 1009 | + |
| 1010 | + #[test] |
| 1011 | + fn encodes_offer_as_bech32_without_checksum() { |
| 1012 | + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy"; |
| 1013 | + let offer = dbg!(encoded_offer.parse::<Offer>().unwrap()); |
| 1014 | + let reencoded_offer = offer.to_string(); |
| 1015 | + dbg!(reencoded_offer.parse::<Offer>().unwrap()); |
| 1016 | + assert_eq!(reencoded_offer, encoded_offer); |
| 1017 | + } |
| 1018 | + |
| 1019 | + #[test] |
| 1020 | + fn parses_bech32_encoded_offers() { |
| 1021 | + let offers = [ |
| 1022 | + // BOLT 12 test vectors |
| 1023 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1024 | + "l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1025 | + "l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1026 | + "lno1qcp4256ypqpq+86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qs+y", |
| 1027 | + "lno1qcp4256ypqpq+ 86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+ 0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+\nsqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43l+\r\nastpwuh73k29qs+\r y", |
| 1028 | + // Two blinded paths |
| 1029 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yg06qg2qdd7t628sgykwj5kuc837qmlv9m9gr7sq8ap6erfgacv26nhp8zzcqgzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6heqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9sqs9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1030 | + ]; |
| 1031 | + for encoded_offer in &offers { |
| 1032 | + // TODO: Use Offer once Destination semantics are finalized. |
| 1033 | + if let Err(e) = encoded_offer.parse::<ParsedOffer>() { |
| 1034 | + panic!("Invalid offer ({:?}): {}", e, encoded_offer); |
| 1035 | + } |
| 1036 | + } |
| 1037 | + } |
| 1038 | + |
| 1039 | + #[test] |
| 1040 | + fn fails_parsing_bech32_encoded_offers_with_invalid_continuations() { |
| 1041 | + let offers = [ |
| 1042 | + // BOLT 12 test vectors |
| 1043 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+", |
| 1044 | + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+ ", |
| 1045 | + "+lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1046 | + "+ lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1047 | + "ln++o1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", |
| 1048 | + ]; |
| 1049 | + for encoded_offer in &offers { |
| 1050 | + match encoded_offer.parse::<Offer>() { |
| 1051 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1052 | + Err(e) => assert_eq!(e, ParseError::InvalidContinuation), |
| 1053 | + } |
| 1054 | + } |
| 1055 | + |
| 1056 | + } |
| 1057 | + |
| 1058 | + #[test] |
| 1059 | + fn fails_parsing_bech32_encoded_offer_with_invalid_hrp() { |
| 1060 | + let encoded_offer = "lni1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy"; |
| 1061 | + match encoded_offer.parse::<Offer>() { |
| 1062 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1063 | + Err(e) => assert_eq!(e, ParseError::InvalidBech32Hrp), |
| 1064 | + } |
| 1065 | + } |
| 1066 | + |
| 1067 | + #[test] |
| 1068 | + fn fails_parsing_bech32_encoded_offer_with_invalid_bech32_data() { |
| 1069 | + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qso"; |
| 1070 | + match encoded_offer.parse::<Offer>() { |
| 1071 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1072 | + Err(e) => assert_eq!(e, ParseError::Bech32(bech32::Error::InvalidChar('o'))), |
| 1073 | + } |
| 1074 | + } |
| 1075 | + |
| 1076 | + #[test] |
| 1077 | + fn fails_parsing_bech32_encoded_offer_with_invalid_tlv_data() { |
| 1078 | + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsyqqqqq"; |
| 1079 | + match encoded_offer.parse::<Offer>() { |
| 1080 | + Ok(_) => panic!("Valid offer: {}", encoded_offer), |
| 1081 | + Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)), |
| 1082 | + } |
| 1083 | + } |
| 1084 | +} |
0 commit comments