1#![deny(missing_docs)]
31#![forbid(unsafe_code)]
32#![deny(rustdoc::broken_intra_doc_links)]
33#![deny(rustdoc::private_intra_doc_links)]
34#![cfg_attr(not(feature = "std"), no_std)]
35
36extern crate alloc;
37extern crate core;
38
39use alloc::borrow::ToOwned;
40use alloc::str::FromStr;
41use alloc::string::String;
42use alloc::vec;
43use alloc::vec::Vec;
44
45use bitcoin::{address, Address, Network};
46use core::time::Duration;
47use lightning::offers::offer::{self, Offer};
48use lightning::offers::parse::Bolt12ParseError;
49use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef, ParseOrSemanticError};
50
51#[cfg(feature = "std")]
52mod dnssec_utils;
53
54#[cfg(feature = "std")]
55pub mod dns_resolver;
56
57#[cfg(feature = "http")]
58pub mod http_resolver;
59
60#[cfg(feature = "std")] pub mod onion_message_resolver;
62
63pub mod amount;
64
65pub mod receive;
66
67pub mod cashu;
68
69pub mod hrn_resolution;
70
71use amount::Amount;
72use hrn_resolution::{HrnResolution, HrnResolver, HumanReadableName};
73
74#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum PaymentMethod {
77 LightningBolt11(Bolt11Invoice),
79 LightningBolt12(Offer),
81 OnChain(Address),
83 Cashu(cashu::CashuPaymentRequest),
85}
86
87impl PaymentMethod {
88 fn amount(&self) -> Option<Amount> {
89 match self {
90 PaymentMethod::LightningBolt11(invoice) => {
91 invoice.amount_milli_satoshis().map(|amt_msat| {
92 let res = Amount::from_milli_sats(amt_msat);
93 debug_assert!(res.is_ok(), "This should be rejected at parse-time");
94 res.unwrap_or(Amount::ZERO)
95 })
96 },
97 PaymentMethod::LightningBolt12(offer) => match offer.amount() {
98 Some(offer::Amount::Bitcoin { amount_msats }) => {
99 let res = Amount::from_milli_sats(amount_msats);
100 debug_assert!(res.is_ok(), "This should be rejected at parse-time");
101 Some(res.unwrap_or(Amount::ZERO))
102 },
103 Some(offer::Amount::Currency { .. }) => None,
104 None => None,
105 },
106 PaymentMethod::OnChain(_) => None,
107 PaymentMethod::Cashu(req) => match req.unit {
108 Some(cashu::CurrencyUnit::Sat) => {
109 req.amount.and_then(|a| Amount::from_sats(a).ok())
110 },
111 Some(cashu::CurrencyUnit::Msat) => {
112 req.amount.and_then(|a| Amount::from_milli_sats(a).ok())
113 },
114 _ => None,
115 },
116 }
117 }
118
119 fn is_lightning(&self) -> bool {
120 match self {
121 PaymentMethod::LightningBolt11(_) => true,
122 PaymentMethod::LightningBolt12(_) => true,
123 PaymentMethod::OnChain(_) => false,
124 PaymentMethod::Cashu(_) => false,
125 }
126 }
127
128 fn has_fixed_amount(&self) -> bool {
129 match self {
130 PaymentMethod::LightningBolt11(invoice) => invoice.amount_milli_satoshis().is_some(),
131 PaymentMethod::LightningBolt12(offer) => match offer.amount() {
132 Some(offer::Amount::Bitcoin { .. }) => true,
133 Some(offer::Amount::Currency { .. }) => true,
134 None => false,
135 },
136 PaymentMethod::OnChain(_) => false,
137 PaymentMethod::Cashu(req) => req.amount.is_some(),
138 }
139 }
140}
141
142pub enum PossiblyResolvedPaymentMethod<'a> {
144 LNURLPay {
147 min_value: Amount,
149 max_value: Amount,
151 callback: &'a str,
154 },
155 Resolved(&'a PaymentMethod),
157}
158
159pub enum PaymentMethodType {
165 LightningBolt11,
168 LightningBolt12,
171 OnChain,
174 Cashu,
177}
178
179impl<'a> PossiblyResolvedPaymentMethod<'a> {
180 pub fn method_type(&self) -> PaymentMethodType {
182 match self {
183 Self::LNURLPay { .. } => PaymentMethodType::LightningBolt11,
184 Self::Resolved(PaymentMethod::LightningBolt11(_)) => PaymentMethodType::LightningBolt11,
185 Self::Resolved(PaymentMethod::LightningBolt12(_)) => PaymentMethodType::LightningBolt12,
186 Self::Resolved(PaymentMethod::OnChain(_)) => PaymentMethodType::OnChain,
187 Self::Resolved(PaymentMethod::Cashu(_)) => PaymentMethodType::Cashu,
188 }
189 }
190}
191
192#[derive(Clone, PartialEq, Eq, Debug)]
193struct PaymentInstructionsImpl {
194 description: Option<String>,
195 methods: Vec<PaymentMethod>,
196 ln_amt: Option<Amount>,
197 cashu_amt: Option<Amount>,
198 onchain_amt: Option<Amount>,
199 lnurl: Option<(String, [u8; 32], Amount, Amount)>,
200 pop_callback: Option<String>,
201 hrn: Option<HumanReadableName>,
202 hrn_proof: Option<Vec<u8>>,
203}
204
205macro_rules! common_methods {
207 ($struct: ty) => {
208 impl $struct {
209 #[inline]
216 pub fn recipient_description(&self) -> Option<&str> {
217 self.inner().description.as_ref().map(|d| d.as_str())
218 }
219
220 #[inline]
227 pub fn pop_callback(&self) -> Option<&str> {
228 self.inner().pop_callback.as_ref().map(|c| c.as_str())
229 }
230
231 #[inline]
234 pub fn human_readable_name(&self) -> &Option<HumanReadableName> {
235 &self.inner().hrn
236 }
237
238 #[inline]
247 pub fn bip_353_dnssec_proof(&self) -> &Option<Vec<u8>> {
248 &self.inner().hrn_proof
249 }
250 }
251 };
252}
253
254#[derive(Clone, PartialEq, Eq, Debug)]
257pub struct FixedAmountPaymentInstructions {
258 inner: PaymentInstructionsImpl,
259}
260
261impl FixedAmountPaymentInstructions {
262 pub fn max_amount(&self) -> Option<Amount> {
271 [self.inner.ln_amt, self.inner.onchain_amt, self.inner.cashu_amt]
272 .into_iter()
273 .flatten()
274 .max()
275 }
276
277 pub fn ln_payment_amount(&self) -> Option<Amount> {
292 self.inner.ln_amt
293 }
294
295 pub fn cashu_payment_amount(&self) -> Option<Amount> {
300 self.inner.cashu_amt
301 }
302
303 pub fn onchain_payment_amount(&self) -> Option<Amount> {
310 self.inner.onchain_amt
311 }
312
313 #[inline]
315 pub fn methods(&self) -> &[PaymentMethod] {
316 &self.inner.methods
317 }
318
319 fn inner(&self) -> &PaymentInstructionsImpl {
320 &self.inner
321 }
322}
323
324common_methods!(FixedAmountPaymentInstructions);
325
326#[derive(Clone, PartialEq, Eq, Debug)]
329pub struct ConfigurableAmountPaymentInstructions {
330 inner: PaymentInstructionsImpl,
331}
332
333impl ConfigurableAmountPaymentInstructions {
334 pub fn min_amt(&self) -> Option<Amount> {
337 self.inner.lnurl.as_ref().map(|(_, _, a, _)| *a)
338 }
339
340 pub fn max_amt(&self) -> Option<Amount> {
343 self.inner.lnurl.as_ref().map(|(_, _, _, a)| *a)
344 }
345
346 #[inline]
351 pub fn methods<'a>(&'a self) -> impl Iterator<Item = PossiblyResolvedPaymentMethod<'a>> {
352 let res = self.inner().methods.iter().map(PossiblyResolvedPaymentMethod::Resolved);
353 res.chain(self.inner().lnurl.iter().map(|(callback, _, min, max)| {
354 PossiblyResolvedPaymentMethod::LNURLPay { callback, min_value: *min, max_value: *max }
355 }))
356 }
357
358 pub async fn set_amount<R: HrnResolver>(
370 self, amount: Amount, resolver: &R,
371 ) -> Result<FixedAmountPaymentInstructions, &'static str> {
372 let mut inner = self.inner;
373 if let Some((callback, expected_desc_hash, min, max)) = inner.lnurl.take() {
374 if amount < min || amount > max {
375 return Err("Amount was not within the min_amt/max_amt bounds");
376 }
377 debug_assert!(inner.methods.is_empty());
378 debug_assert!(inner.onchain_amt.is_none());
379 debug_assert!(inner.cashu_amt.is_none());
380 debug_assert!(inner.pop_callback.is_none());
381 debug_assert!(inner.hrn_proof.is_none());
382 let bolt11 =
383 resolver.resolve_lnurl_to_invoice(callback, amount, expected_desc_hash).await?;
384 if bolt11.amount_milli_satoshis() != Some(amount.milli_sats()) {
385 return Err("LNURL resolution resulted in a BOLT 11 invoice with the wrong amount");
386 }
387 inner.methods = vec![PaymentMethod::LightningBolt11(bolt11)];
388 inner.ln_amt = Some(amount);
389 } else {
390 if inner.methods.iter().any(|meth| matches!(meth, PaymentMethod::OnChain(_))) {
391 let amt = Amount::from_sats((amount.milli_sats() + 999) / 1000)
392 .map_err(|_| "Requested amount was too close to 21M sats to round up")?;
393 inner.onchain_amt = Some(amt);
394 }
395 if inner.methods.iter().any(|meth| meth.is_lightning()) {
396 inner.ln_amt = Some(amount);
397 }
398 if inner.methods.iter().any(|meth| matches!(meth, PaymentMethod::Cashu(_))) {
399 inner.cashu_amt = Some(amount);
400 }
401 }
402 Ok(FixedAmountPaymentInstructions { inner })
403 }
404
405 fn inner(&self) -> &PaymentInstructionsImpl {
406 &self.inner
407 }
408}
409
410common_methods!(ConfigurableAmountPaymentInstructions);
411
412#[derive(Clone, PartialEq, Eq, Debug)]
423pub enum PaymentInstructions {
424 ConfigurableAmount(ConfigurableAmountPaymentInstructions),
432 FixedAmount(FixedAmountPaymentInstructions),
437}
438
439common_methods!(PaymentInstructions);
440
441impl PaymentInstructions {
442 fn inner(&self) -> &PaymentInstructionsImpl {
443 match self {
444 PaymentInstructions::ConfigurableAmount(inner) => &inner.inner,
445 PaymentInstructions::FixedAmount(inner) => &inner.inner,
446 }
447 }
448}
449
450pub const MAX_AMOUNT_DIFFERENCE: Amount = Amount::from_sats_panicy(100);
456
457#[derive(Debug)]
459#[cfg_attr(test, derive(PartialEq))]
460pub enum ParseError {
461 InvalidBolt11(ParseOrSemanticError),
463 InvalidBolt12(Bolt12ParseError),
465 InvalidOnChain(address::ParseError),
467 InvalidCashu(cashu::Error),
469 InvalidLnurl(&'static str),
471 WrongNetwork,
473 InconsistentInstructions(&'static str),
478 InvalidInstructions(&'static str),
483 UnknownPaymentInstructions,
485 UnknownRequiredParameter,
487 HrnResolutionError(&'static str),
489 InstructionsExpired,
491}
492
493fn check_expiry(_expiry: Duration) -> Result<(), ParseError> {
494 #[cfg(feature = "std")]
495 {
496 use std::time::SystemTime;
497 if let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
498 if now > _expiry {
499 return Err(ParseError::InstructionsExpired);
500 }
501 }
502 }
503 Ok(())
504}
505
506struct Bolt11Amounts {
507 ln_amount: Option<Amount>,
508 fallbacks_amount: Option<Amount>,
509}
510
511fn instructions_from_bolt11(
512 invoice: Bolt11Invoice, network: Network,
513) -> Result<(Option<String>, Bolt11Amounts, impl Iterator<Item = PaymentMethod>), ParseError> {
514 if invoice.network() != network {
515 return Err(ParseError::WrongNetwork);
516 }
517 if let Some(expiry) = invoice.expires_at() {
518 check_expiry(expiry)?;
519 }
520
521 let fallbacks = invoice.fallback_addresses().into_iter().map(PaymentMethod::OnChain);
522
523 let mut fallbacks_amount = None;
524 let mut ln_amount = None;
525 if let Some(amt_msat) = invoice.amount_milli_satoshis() {
526 let err = "BOLT 11 invoice required an amount greater than 21M BTC";
527 ln_amount = Some(
528 Amount::from_milli_sats(amt_msat).map_err(|_| ParseError::InvalidInstructions(err))?,
529 );
530 if !invoice.fallbacks().is_empty() {
531 fallbacks_amount = Some(
532 Amount::from_sats((amt_msat + 999) / 1000)
533 .map_err(|_| ParseError::InvalidInstructions(err))?,
534 );
535 }
536 }
537
538 let amounts = Bolt11Amounts { ln_amount, fallbacks_amount };
539
540 if let Bolt11InvoiceDescriptionRef::Direct(desc) = invoice.description() {
541 Ok((
542 Some(desc.as_inner().0.clone()),
543 amounts,
544 Some(PaymentMethod::LightningBolt11(invoice)).into_iter().chain(fallbacks),
545 ))
546 } else {
547 Ok((
548 None,
549 amounts,
550 Some(PaymentMethod::LightningBolt11(invoice)).into_iter().chain(fallbacks),
551 ))
552 }
553}
554
555fn check_offer(offer: Offer, net: Network) -> Result<(Option<String>, PaymentMethod), ParseError> {
556 if !offer.supports_chain(net.chain_hash()) {
557 return Err(ParseError::WrongNetwork);
558 }
559 if let Some(expiry) = offer.absolute_expiry() {
560 check_expiry(expiry)?;
561 }
562 let description = offer.description().map(|desc| desc.0.to_owned());
563 if let Some(offer::Amount::Bitcoin { amount_msats }) = offer.amount() {
564 if Amount::from_milli_sats(amount_msats).is_err() {
565 let err = "BOLT 12 offer requested an amount greater than 21M BTC";
566 return Err(ParseError::InvalidInstructions(err));
567 }
568 }
569 Ok((description, PaymentMethod::LightningBolt12(offer)))
570}
571
572fn split_once(haystack: &str, needle: char) -> (&str, Option<&str>) {
574 haystack.split_once(needle).map(|(a, b)| (a, Some(b))).unwrap_or((haystack, None))
575}
576
577fn un_percent_encode(encoded: &str) -> Result<String, ParseError> {
578 let mut res = Vec::with_capacity(encoded.len());
579 let mut iter = encoded.bytes();
580 let err = "A Proof of Payment URI was not properly %-encoded in a BIP 321 bitcoin: URI";
581 while let Some(b) = iter.next() {
582 if b == b'%' {
583 let high = iter.next().ok_or(ParseError::InvalidInstructions(err))?;
584 let low = iter.next().ok_or(ParseError::InvalidInstructions(err))?;
585 if !high.is_ascii_digit() || !low.is_ascii_digit() {
586 return Err(ParseError::InvalidInstructions(err));
587 }
588 res.push(((high - b'0') << 4) | (low - b'0'));
589 } else {
590 res.push(b);
591 }
592 }
593 String::from_utf8(res).map_err(|_| ParseError::InvalidInstructions(err))
594}
595
596#[test]
597fn test_un_percent_encode() {
598 assert_eq!(un_percent_encode("%20").unwrap(), " ");
599 assert_eq!(un_percent_encode("42%20 ").unwrap(), "42 ");
600 assert!(un_percent_encode("42%2").is_err());
601 assert!(un_percent_encode("42%2a").is_err());
602}
603
604fn parse_resolved_instructions(
605 instructions: &str, network: Network, supports_proof_of_payment_callbacks: bool,
606 hrn: Option<HumanReadableName>, hrn_proof: Option<Vec<u8>>,
607) -> Result<PaymentInstructions, ParseError> {
608 let (uri_proto, uri_suffix) = split_once(instructions, ':');
609
610 if uri_proto.eq_ignore_ascii_case("bitcoin") {
611 let (body, params) = split_once(uri_suffix.unwrap_or(""), '?');
612 let mut methods = Vec::new();
613 let mut description = None;
614 let mut pop_callback = None;
615 if !body.is_empty() {
616 let addr = Address::from_str(body).map_err(ParseError::InvalidOnChain)?;
617 let address = addr.require_network(network).map_err(|_| ParseError::WrongNetwork)?;
618 methods.push(PaymentMethod::OnChain(address));
619 }
620 if let Some(params) = params {
621 let mut onchain_amt = None;
622 for param in params.split('&') {
623 let (k, v) = split_once(param, '=');
624
625 let mut parse_segwit = |pfx| {
626 if let Some(address_string) = v {
627 if address_string.is_char_boundary(3)
628 && !address_string[..3].eq_ignore_ascii_case(pfx)
629 {
630 let err = "BIP 321 bitcoin: URI contained a bc/tb instruction which was not a Segwit address (bc1*/tb1*)";
633 return Err(ParseError::InvalidInstructions(err));
634 }
635 let addr = Address::from_str(address_string)
636 .map_err(ParseError::InvalidOnChain)?;
637 let address =
638 addr.require_network(network).map_err(|_| ParseError::WrongNetwork)?;
639 methods.push(PaymentMethod::OnChain(address));
640 } else {
641 let err = "BIP 321 bitcoin: URI contained a bc (Segwit address) instruction without a value";
642 return Err(ParseError::InvalidInstructions(err));
643 }
644 Ok(())
645 };
646 if k.eq_ignore_ascii_case("bc") || k.eq_ignore_ascii_case("req-bc") {
647 parse_segwit("bc1")?;
648 } else if k.eq_ignore_ascii_case("tb") || k.eq_ignore_ascii_case("req-tb") {
649 parse_segwit("tb1")?;
650 } else if k.eq_ignore_ascii_case("lightning")
651 || k.eq_ignore_ascii_case("req-lightning")
652 {
653 if let Some(invoice_string) = v {
654 let invoice = Bolt11Invoice::from_str(invoice_string)
655 .map_err(ParseError::InvalidBolt11)?;
656 let (desc, amounts, method_iter) =
657 instructions_from_bolt11(invoice, network)?;
658 if let Some(fallbacks_amt) = amounts.fallbacks_amount {
659 if onchain_amt.is_some() && onchain_amt != Some(fallbacks_amt) {
660 let err = "BIP 321 bitcoin: URI contains lightning (BOLT 11 invoice) instructions with varying values";
661 return Err(ParseError::InconsistentInstructions(err));
662 }
663 onchain_amt = Some(fallbacks_amt);
664 }
665 if let Some(desc) = desc {
666 description = Some(desc);
667 }
668 for method in method_iter {
669 methods.push(method);
670 }
671 } else {
672 let err = "BIP 321 bitcoin: URI contained a lightning (BOLT 11 invoice) instruction without a value";
673 return Err(ParseError::InvalidInstructions(err));
674 }
675 } else if k.eq_ignore_ascii_case("creq") || k.eq_ignore_ascii_case("req-creq") {
676 if let Some(creq_string) = v {
677 let creq = cashu::CashuPaymentRequest::from_str(creq_string)
678 .map_err(ParseError::InvalidCashu)?;
679 if let Some(desc) = &creq.description {
680 description = Some(desc.clone());
681 }
682 methods.push(PaymentMethod::Cashu(creq));
683 } else {
684 let err = "BIP 321 bitcoin: URI contained a creq (Cashu) instruction without a value";
685 return Err(ParseError::InvalidInstructions(err));
686 }
687 } else if k.eq_ignore_ascii_case("lno") || k.eq_ignore_ascii_case("req-lno") {
688 if let Some(offer_string) = v {
689 let offer =
690 Offer::from_str(offer_string).map_err(ParseError::InvalidBolt12)?;
691 let (desc, method) = check_offer(offer, network)?;
692 if let Some(desc) = desc {
693 description = Some(desc);
694 }
695 methods.push(method);
696 } else {
697 let err = "BIP 321 bitcoin: URI contained a lightning (BOLT 11 invoice) instruction without a value";
698 return Err(ParseError::InvalidInstructions(err));
699 }
700 } else if k.eq_ignore_ascii_case("amount") || k.eq_ignore_ascii_case("req-amount") {
701 } else if k.eq_ignore_ascii_case("label") || k.eq_ignore_ascii_case("req-label") {
703 } else if k.eq_ignore_ascii_case("message") || k.eq_ignore_ascii_case("req-message")
705 {
706 } else if k.eq_ignore_ascii_case("pop") || k.eq_ignore_ascii_case("req-pop") {
708 if k.eq_ignore_ascii_case("req-pop") && !supports_proof_of_payment_callbacks {
709 return Err(ParseError::UnknownRequiredParameter);
710 }
711 if pop_callback.is_some() {
712 let err = "Multiple proof of payment callbacks appeared in a BIP 321 bitcoin: URI";
713 return Err(ParseError::InvalidInstructions(err));
714 }
715 if let Some(v) = v {
716 let callback_uri = un_percent_encode(v)?;
717 let (proto, _) = split_once(&callback_uri, ':');
718 let proto_isnt_local_app = proto.eq_ignore_ascii_case("javascript")
719 || proto.eq_ignore_ascii_case("http")
720 || proto.eq_ignore_ascii_case("https")
721 || proto.eq_ignore_ascii_case("file")
722 || proto.eq_ignore_ascii_case("mailto")
723 || proto.eq_ignore_ascii_case("ftp")
724 || proto.eq_ignore_ascii_case("wss")
725 || proto.eq_ignore_ascii_case("ws")
726 || proto.eq_ignore_ascii_case("ssh")
727 || proto.eq_ignore_ascii_case("tel") || proto.eq_ignore_ascii_case("data")
729 || proto.eq_ignore_ascii_case("blob");
730 if proto_isnt_local_app {
731 let err = "Proof of payment callback would not have opened a local app";
732 return Err(ParseError::InvalidInstructions(err));
733 }
734 pop_callback = Some(callback_uri);
735 } else {
736 let err = "Missing value for a Proof of Payment instruction in a BIP 321 bitcoin: URI";
737 return Err(ParseError::InvalidInstructions(err));
738 }
739 } else if k.is_char_boundary(4) && k[..4].eq_ignore_ascii_case("req-") {
740 return Err(ParseError::UnknownRequiredParameter);
741 }
742 }
743 let mut label = None;
744 let mut message = None;
745 let mut had_amt_param = false;
746 for param in params.split('&') {
747 let (k, v) = split_once(param, '=');
748 if k.eq_ignore_ascii_case("amount") || k.eq_ignore_ascii_case("req-amount") {
749 if let Some(v) = v {
750 if had_amt_param {
751 let err = "Multiple amount parameters in a BIP 321 bitcoin: URI ";
752 return Err(ParseError::InvalidInstructions(err));
753 }
754 had_amt_param = true;
755
756 let err = "The amount parameter in a BIP 321 bitcoin: URI was invalid";
757 let btc_amt =
758 bitcoin::Amount::from_str_in(v, bitcoin::Denomination::Bitcoin)
759 .map_err(|_| ParseError::InvalidInstructions(err))?;
760
761 let err = "The amount parameter in a BIP 321 bitcoin: URI was greater than 21M BTC";
762 let amount = Amount::from_sats(btc_amt.to_sat())
763 .map_err(|_| ParseError::InvalidInstructions(err))?;
764
765 if onchain_amt.is_some() && onchain_amt != Some(amount) {
766 let err = "On-chain fallbacks from a lightning BOLT 11 invoice and the amount parameter in a BIP 321 bitcoin: URI differed in their amounts";
767 return Err(ParseError::InconsistentInstructions(err));
768 }
769 onchain_amt = Some(amount);
770 } else {
771 let err = "Missing value for an amount parameter in a BIP 321 bitcoin: URI";
772 return Err(ParseError::InvalidInstructions(err));
773 }
774 } else if k.eq_ignore_ascii_case("label") || k.eq_ignore_ascii_case("req-label") {
775 if label.is_some() {
776 let err = "Multiple label parameters in a BIP 321 bitcoin: URI";
777 return Err(ParseError::InvalidInstructions(err));
778 }
779 label = v;
780 } else if k.eq_ignore_ascii_case("message") || k.eq_ignore_ascii_case("req-message")
781 {
782 if message.is_some() {
783 let err = "Multiple message parameters in a BIP 321 bitcoin: URI";
784 return Err(ParseError::InvalidInstructions(err));
785 }
786 message = v;
787 }
788 }
789
790 if methods.is_empty() {
791 return Err(ParseError::UnknownPaymentInstructions);
792 }
793
794 let mut min_amt = Amount::MAX;
795 let mut max_amt = Amount::ZERO;
796 let mut ln_amt = None;
797 let mut cashu_amt = None;
798 let mut have_amountless_method = false;
799 let mut have_non_btc_denominated_method = false;
800 for method in methods.iter() {
801 let amt = match method {
802 PaymentMethod::LightningBolt11(_)
803 | PaymentMethod::LightningBolt12(_)
804 | PaymentMethod::Cashu(_) => method.amount(),
805 PaymentMethod::OnChain(_) => onchain_amt,
806 };
807 if let Some(amt) = amt {
808 if amt < min_amt {
809 min_amt = amt;
810 }
811 if amt > max_amt {
812 max_amt = amt;
813 }
814 match method {
815 PaymentMethod::LightningBolt11(_) | PaymentMethod::LightningBolt12(_) => {
816 if let Some(ln_amt) = ln_amt {
817 if ln_amt != amt {
818 let err = "Had multiple different amounts in lightning payment methods in a BIP 321 bitcoin: URI";
819 return Err(ParseError::InconsistentInstructions(err));
820 }
821 }
822 ln_amt = Some(amt);
823 },
824 PaymentMethod::Cashu(_) => {
825 if let Some(c_amt) = cashu_amt {
826 if c_amt != amt {
827 let err = "Had multiple different amounts in Cashu payment methods in a BIP 321 bitcoin: URI";
828 return Err(ParseError::InconsistentInstructions(err));
829 }
830 }
831 cashu_amt = Some(amt);
832 },
833 PaymentMethod::OnChain(_) => {},
834 }
835 } else if method.has_fixed_amount() {
836 have_non_btc_denominated_method = true;
837 } else {
838 have_amountless_method = true;
839 }
840 }
841 if have_amountless_method && have_non_btc_denominated_method {
842 let err = "Had some payment methods in a BIP 321 bitcoin: URI with required (non-BTC-denominated) amounts, some without";
843 return Err(ParseError::InconsistentInstructions(err));
844 }
845 let cant_have_amt = have_amountless_method || have_non_btc_denominated_method;
846 if (min_amt != Amount::MAX || max_amt != Amount::ZERO) && cant_have_amt {
847 let err = "Had some payment methods in a BIP 321 bitcoin: URI with required amounts, some without";
848 return Err(ParseError::InconsistentInstructions(err));
849 }
850 if max_amt.saturating_sub(min_amt) > MAX_AMOUNT_DIFFERENCE {
851 let err = "Payment methods differed in ";
852 return Err(ParseError::InconsistentInstructions(err));
853 }
854
855 let inner = PaymentInstructionsImpl {
856 description,
857 methods,
858 onchain_amt,
859 ln_amt,
860 cashu_amt,
861 lnurl: None,
862 pop_callback,
863 hrn,
864 hrn_proof,
865 };
866 if !have_amountless_method || have_non_btc_denominated_method {
867 Ok(PaymentInstructions::FixedAmount(FixedAmountPaymentInstructions { inner }))
868 } else {
869 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
870 inner,
871 }))
872 }
873 } else {
874 if methods.is_empty() {
876 Err(ParseError::UnknownPaymentInstructions)
877 } else {
878 let inner = PaymentInstructionsImpl {
879 description,
880 methods,
881 onchain_amt: None,
882 ln_amt: None,
883 cashu_amt: None,
884 lnurl: None,
885 pop_callback,
886 hrn,
887 hrn_proof,
888 };
889 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
890 inner,
891 }))
892 }
893 }
894 } else if uri_proto.eq_ignore_ascii_case("lightning") {
895 let invoice =
898 Bolt11Invoice::from_str(uri_suffix.unwrap_or("")).map_err(ParseError::InvalidBolt11)?;
899 let (description, amounts, method_iter) = instructions_from_bolt11(invoice, network)?;
900 let inner = PaymentInstructionsImpl {
901 description,
902 methods: method_iter.collect(),
903 onchain_amt: amounts.fallbacks_amount,
904 ln_amt: amounts.ln_amount,
905 cashu_amt: None,
906 lnurl: None,
907 pop_callback: None,
908 hrn,
909 hrn_proof,
910 };
911 if amounts.ln_amount.is_some() {
912 Ok(PaymentInstructions::FixedAmount(FixedAmountPaymentInstructions { inner }))
913 } else {
914 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
915 inner,
916 }))
917 }
918 } else if let Ok(addr) = Address::from_str(instructions) {
919 let address = addr.require_network(network).map_err(|_| ParseError::WrongNetwork)?;
920 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
921 inner: PaymentInstructionsImpl {
922 description: None,
923 methods: vec![PaymentMethod::OnChain(address)],
924 onchain_amt: None,
925 ln_amt: None,
926 cashu_amt: None,
927 lnurl: None,
928 pop_callback: None,
929 hrn,
930 hrn_proof,
931 },
932 }))
933 } else if let Ok(invoice) = Bolt11Invoice::from_str(instructions) {
934 let (description, amounts, method_iter) = instructions_from_bolt11(invoice, network)?;
935 let inner = PaymentInstructionsImpl {
936 description,
937 methods: method_iter.collect(),
938 onchain_amt: amounts.fallbacks_amount,
939 ln_amt: amounts.ln_amount,
940 cashu_amt: None,
941 lnurl: None,
942 pop_callback: None,
943 hrn,
944 hrn_proof,
945 };
946 if amounts.ln_amount.is_some() {
947 Ok(PaymentInstructions::FixedAmount(FixedAmountPaymentInstructions { inner }))
948 } else {
949 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
950 inner,
951 }))
952 }
953 } else if let Ok(creq) = cashu::CashuPaymentRequest::from_str(instructions) {
954 let has_amt = creq.amount.is_some()
955 && (creq.unit == Some(cashu::CurrencyUnit::Sat)
956 || creq.unit == Some(cashu::CurrencyUnit::Msat));
957 let description = creq.description.clone();
958 let cashu_amt = if has_amt {
959 match creq.unit {
960 Some(cashu::CurrencyUnit::Sat) => {
961 creq.amount.and_then(|a| Amount::from_sats(a).ok())
962 },
963 Some(cashu::CurrencyUnit::Msat) => {
964 creq.amount.and_then(|a| Amount::from_milli_sats(a).ok())
965 },
966 _ => None,
967 }
968 } else {
969 None
970 };
971 let inner = PaymentInstructionsImpl {
972 description,
973 methods: vec![PaymentMethod::Cashu(creq)],
974 onchain_amt: None,
975 ln_amt: None,
976 cashu_amt,
977 lnurl: None,
978 pop_callback: None,
979 hrn,
980 hrn_proof,
981 };
982 if has_amt {
983 Ok(PaymentInstructions::FixedAmount(FixedAmountPaymentInstructions { inner }))
984 } else {
985 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
986 inner,
987 }))
988 }
989 } else if let Ok(offer) = Offer::from_str(instructions) {
990 let has_amt = offer.amount().is_some();
991 let (description, method) = check_offer(offer, network)?;
992 let inner = PaymentInstructionsImpl {
993 ln_amt: method.amount(),
994 description,
995 methods: vec![method],
996 onchain_amt: None,
997 cashu_amt: None,
998 lnurl: None,
999 pop_callback: None,
1000 hrn,
1001 hrn_proof,
1002 };
1003 if has_amt {
1004 Ok(PaymentInstructions::FixedAmount(FixedAmountPaymentInstructions { inner }))
1005 } else {
1006 Ok(PaymentInstructions::ConfigurableAmount(ConfigurableAmountPaymentInstructions {
1007 inner,
1008 }))
1009 }
1010 } else {
1011 Err(ParseError::UnknownPaymentInstructions)
1012 }
1013}
1014
1015impl PaymentInstructions {
1016 pub async fn parse<H: HrnResolver>(
1018 instructions: &str, network: Network, hrn_resolver: &H,
1019 supports_proof_of_payment_callbacks: bool,
1020 ) -> Result<PaymentInstructions, ParseError> {
1021 let supports_pops = supports_proof_of_payment_callbacks;
1022 let (uri_proto, _uri_suffix) = split_once(instructions, ':');
1023
1024 if let Ok(hrn) = HumanReadableName::from_encoded(instructions) {
1025 let resolution = hrn_resolver.resolve_hrn(&hrn).await;
1026 let resolution = resolution.map_err(ParseError::HrnResolutionError)?;
1027 match resolution {
1028 HrnResolution::DNSSEC { proof, result } => {
1029 parse_resolved_instructions(&result, network, supports_pops, Some(hrn), proof)
1030 },
1031 HrnResolution::LNURLPay {
1032 min_value,
1033 max_value,
1034 expected_description_hash,
1035 recipient_description,
1036 callback,
1037 } => {
1038 let inner = PaymentInstructionsImpl {
1039 description: recipient_description,
1040 methods: Vec::new(),
1041 lnurl: Some((callback, expected_description_hash, min_value, max_value)),
1042 onchain_amt: None,
1043 ln_amt: None,
1044 cashu_amt: None,
1045 pop_callback: None,
1046 hrn: Some(hrn),
1047 hrn_proof: None,
1048 };
1049 Ok(PaymentInstructions::ConfigurableAmount(
1050 ConfigurableAmountPaymentInstructions { inner },
1051 ))
1052 },
1053 }
1054 } else if uri_proto.eq_ignore_ascii_case("bitcoin:") {
1055 parse_resolved_instructions(instructions, network, supports_pops, None, None)
1058 } else if let Some(idx) = instructions.to_ascii_lowercase().rfind("lnurl") {
1059 let mut lnurl_str = &instructions[idx..];
1060 if let Some(idx) = lnurl_str.find('&') {
1063 lnurl_str = &lnurl_str[..idx];
1064 }
1065 if let Some(idx) = lnurl_str.find('#') {
1066 lnurl_str = &lnurl_str[..idx];
1067 }
1068 if let Ok((_, data)) = bitcoin::bech32::decode(lnurl_str) {
1069 let url = String::from_utf8(data)
1070 .map_err(|_| ParseError::InvalidLnurl("Not utf-8 encoded string"))?;
1071 let resolution = hrn_resolver.resolve_lnurl(&url).await;
1072 let resolution = resolution.map_err(ParseError::HrnResolutionError)?;
1073 match resolution {
1074 HrnResolution::DNSSEC { .. } => Err(ParseError::HrnResolutionError(
1075 "Unexpected return when resolving lnurl",
1076 )),
1077 HrnResolution::LNURLPay {
1078 min_value,
1079 max_value,
1080 expected_description_hash,
1081 recipient_description,
1082 callback,
1083 } => {
1084 let inner = PaymentInstructionsImpl {
1085 description: recipient_description,
1086 methods: Vec::new(),
1087 lnurl: Some((
1088 callback,
1089 expected_description_hash,
1090 min_value,
1091 max_value,
1092 )),
1093 onchain_amt: None,
1094 ln_amt: None,
1095 cashu_amt: None,
1096 pop_callback: None,
1097 hrn: None,
1098 hrn_proof: None,
1099 };
1100 Ok(PaymentInstructions::ConfigurableAmount(
1101 ConfigurableAmountPaymentInstructions { inner },
1102 ))
1103 },
1104 }
1105 } else {
1106 parse_resolved_instructions(instructions, network, supports_pops, None, None)
1107 }
1108 } else {
1109 parse_resolved_instructions(instructions, network, supports_pops, None, None)
1110 }
1111 }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use alloc::format;
1117 use alloc::str::FromStr;
1118 #[cfg(not(feature = "std"))]
1119 use alloc::string::ToString;
1120
1121 use super::*;
1122
1123 use crate::hrn_resolution::DummyHrnResolver;
1124
1125 const SAMPLE_INVOICE_WITH_FALLBACK: &str = "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0";
1126 const SAMPLE_INVOICE: &str = "lnbc20m1pn7qa2ndqqnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5kwzshmne5zw3lnfqdk8cv26mg9ndjapqzhcxn2wtn9d6ew5e2jfqsp5h3u5f0l522vs488h6n8zm5ca2lkpva532fnl2kp4wnvsuq445erq9qyysgqcqpcxqppz4395v2sjh3t5pzckgeelk9qf0z3fm9jzxtjqpqygayt4xyy7tpjvq5pe7f6727du2mg3t2tfe0cd53de2027ff7es7smtew8xx5x2spwuvkdz";
1127 const SAMPLE_OFFER: &str = "lno1qgs0v8hw8d368q9yw7sx8tejk2aujlyll8cp7tzzyh5h8xyppqqqqqqgqvqcdgq2qenxzatrv46pvggrv64u366d5c0rr2xjc3fq6vw2hh6ce3f9p7z4v4ee0u7avfynjw9q";
1128 const SAMPLE_BIP21: &str = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz";
1129
1130 #[cfg(feature = "http")]
1131 const SAMPLE_LNURL: &str = "LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
1132 #[cfg(feature = "http")]
1133 const SAMPLE_LNURL_LN_PREFIX: &str = "lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
1134 #[cfg(feature = "http")]
1135 const SAMPLE_LNURL_FALLBACK: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK";
1136 #[cfg(feature = "http")]
1137 const SAMPLE_LNURL_FALLBACK_WITH_AND: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK&extra=my_extra_param";
1138 #[cfg(feature = "http")]
1139 const SAMPLE_LNURL_FALLBACK_WITH_HASHTAG: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK#extra=my_extra_param";
1140 #[cfg(feature = "http")]
1141 const SAMPLE_LNURL_FALLBACK_WITH_BOTH: &str = "https://service.com/giftcard/redeem?id=123&lightning=LNURL1DP68GURN8GHJ7MRWW4EXCTNDW46XJMNEDEJHGTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHHYETXW4HXG0AH8NK&extra=my_extra_param#extra2=another_extra_param";
1142
1143 const SAMPLE_BIP21_WITH_INVOICE: &str = "bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6";
1144 #[cfg(not(feature = "std"))]
1145 const SAMPLE_BIP21_WITH_INVOICE_ADDR: &str = "bc1qylh3u67j673h6y6alv70m0pl2yz53tzhvxgg7u";
1146 #[cfg(not(feature = "std"))]
1147 const SAMPLE_BIP21_WITH_INVOICE_INVOICE: &str = "lnbc10u1p3pj257pp5yztkwjcz5ftl5laxkav23zmzekaw37zk6kmv80pk4xaev5qhtz7qdpdwd3xger9wd5kwm36yprx7u3qd36kucmgyp282etnv3shjcqzpgxqyz5vqsp5usyc4lk9chsfp53kvcnvq456ganh60d89reykdngsmtj6yw3nhvq9qyyssqjcewm5cjwz4a6rfjx77c490yced6pemk0upkxhy89cmm7sct66k8gneanwykzgdrwrfje69h9u5u0w57rrcsysas7gadwmzxc8c6t0spjazup6";
1148
1149 const SAMPLE_BIP21_WITH_INVOICE_AND_LABEL: &str = "bitcoin:tb1p0vztr8q25czuka5u4ta5pqu0h8dxkf72mam89cpg4tg40fm8wgmqp3gv99?amount=0.000001&label=yooo&lightning=lntbs1u1pjrww6fdq809hk7mcnp4qvwggxr0fsueyrcer4x075walsv93vqvn3vlg9etesx287x6ddy4xpp5a3drwdx2fmkkgmuenpvmynnl7uf09jmgvtlg86ckkvgn99ajqgtssp5gr3aghgjxlwshnqwqn39c2cz5hw4cnsnzxdjn7kywl40rru4mjdq9qyysgqcqpcxqrpwurzjqfgtsj42x8an5zujpxvfhp9ngwm7u5lu8lvzfucjhex4pq8ysj5q2qqqqyqqv9cqqsqqqqlgqqqqqqqqfqzgl9zq04nzpxyvdr8vj3h98gvnj3luanj2cxcra0q2th4xjsxmtj8k3582l67xq9ffz5586f3nm5ax58xaqjg6rjcj2vzvx2q39v9eqpn0wx54";
1150
1151 #[tokio::test]
1152 async fn parse_cashu() {
1153 let creq = "CREQB1QYQQWER9D4HNZV3NQGQQSQQQQQQQQQQRAQPSQQGQQSQQZQG9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RQQRJRDANXVET9YPCXZ7TDV4H8GXHR3TQ";
1154 let parsed = PaymentInstructions::parse(creq, Network::Bitcoin, &DummyHrnResolver, false)
1155 .await
1156 .unwrap();
1157
1158 let parsed = match parsed {
1159 PaymentInstructions::FixedAmount(parsed) => parsed,
1160 _ => panic!("Expected FixedAmount for Cashu with amount"),
1161 };
1162
1163 assert_eq!(parsed.methods().len(), 1);
1164 assert_eq!(parsed.cashu_payment_amount(), Some(Amount::from_sats(1000).unwrap()));
1165 assert_eq!(parsed.max_amount(), Some(Amount::from_sats(1000).unwrap()));
1167 assert_eq!(parsed.recipient_description(), Some("Coffee payment"));
1168
1169 if let PaymentMethod::Cashu(req) = &parsed.methods()[0] {
1170 assert_eq!(req.amount, Some(1000));
1171 assert_eq!(req.unit, Some(cashu::CurrencyUnit::Sat));
1172 } else {
1173 panic!("Wrong method type");
1174 }
1175 }
1176
1177 #[tokio::test]
1178 async fn parse_bip_21_with_creq() {
1179 let creq = "CREQB1QYQQWER9D4HNZV3NQGQQSQQQQQQQQQQRAQPSQQGQQSQQZQG9QQVXSAR5WPEN5TE0D45KUAPWV4UXZMTSD3JJUCM0D5RQQRJRDANXVET9YPCXZ7TDV4H8GXHR3TQ";
1180 let uri = format!("bitcoin:?creq={}", creq);
1181
1182 let parsed = PaymentInstructions::parse(&uri, Network::Bitcoin, &DummyHrnResolver, false)
1183 .await
1184 .unwrap();
1185
1186 let parsed = match parsed {
1187 PaymentInstructions::FixedAmount(parsed) => parsed,
1188 _ => panic!("Expected FixedAmount"),
1189 };
1190
1191 assert_eq!(parsed.methods().len(), 1);
1192 assert_eq!(parsed.max_amount(), Some(Amount::from_sats(1000).unwrap()));
1193 if let PaymentMethod::Cashu(_) = &parsed.methods()[0] {
1194 } else {
1196 panic!("Wrong method");
1197 }
1198 }
1199
1200 #[tokio::test]
1201 async fn parse_address() {
1202 let addr_str = "1andreas3batLhQa2FawWjeyjCqyBzypd";
1203 let parsed =
1204 PaymentInstructions::parse(&addr_str, Network::Bitcoin, &DummyHrnResolver, false)
1205 .await
1206 .unwrap();
1207
1208 assert_eq!(parsed.recipient_description(), None);
1209
1210 let amount = Amount::from_sats(10_000).unwrap();
1211
1212 let resolved = match parsed {
1213 PaymentInstructions::ConfigurableAmount(parsed) => {
1214 assert_eq!(parsed.min_amt(), None);
1215 assert_eq!(parsed.min_amt(), None);
1216 assert_eq!(parsed.methods().collect::<Vec<_>>().len(), 1);
1217 parsed.set_amount(amount, &DummyHrnResolver).await.unwrap()
1218 },
1219 _ => panic!(),
1220 };
1221
1222 assert_eq!(resolved.methods().len(), 1);
1223 assert_eq!(resolved.onchain_payment_amount(), Some(amount));
1224 if let PaymentMethod::OnChain(address) = &resolved.methods()[0] {
1225 assert_eq!(*address, Address::from_str(addr_str).unwrap().assume_checked());
1226 } else {
1227 panic!("Wrong method");
1228 }
1229 }
1230
1231 async fn check_ln_invoice(inv: &str) -> Result<PaymentInstructions, ParseError> {
1233 assert!(inv.chars().all(|c| c.is_ascii_lowercase() || c.is_digit(10)), "{}", inv);
1234 let resolver = &DummyHrnResolver;
1235 let raw = PaymentInstructions::parse(inv, Network::Bitcoin, resolver, false).await;
1236
1237 let ln_uri = format!("lightning:{}", inv);
1238 let uri = PaymentInstructions::parse(&ln_uri, Network::Bitcoin, resolver, false).await;
1239 assert_eq!(raw, uri);
1240
1241 let ln_uri = format!("LIGHTNING:{}", inv);
1242 let uri = PaymentInstructions::parse(&ln_uri, Network::Bitcoin, resolver, false).await;
1243 assert_eq!(raw, uri);
1244
1245 let ln_uri = ln_uri.to_uppercase();
1246 let uri = PaymentInstructions::parse(&ln_uri, Network::Bitcoin, resolver, false).await;
1247 assert_eq!(raw, uri);
1248
1249 let btc_uri = format!("bitcoin:?lightning={}", inv);
1250 let uri = PaymentInstructions::parse(&btc_uri, Network::Bitcoin, resolver, false).await;
1251 assert_eq!(raw, uri);
1252
1253 let btc_uri = btc_uri.to_uppercase();
1254 let uri = PaymentInstructions::parse(&btc_uri, Network::Bitcoin, resolver, false).await;
1255 assert_eq!(raw, uri);
1256
1257 let btc_uri = format!("bitcoin:?req-lightning={}", inv);
1258 let uri = PaymentInstructions::parse(&btc_uri, Network::Bitcoin, resolver, false).await;
1259 assert_eq!(raw, uri);
1260
1261 let btc_uri = btc_uri.to_uppercase();
1262 let uri = PaymentInstructions::parse(&btc_uri, Network::Bitcoin, resolver, false).await;
1263 assert_eq!(raw, uri);
1264
1265 raw
1266 }
1267
1268 #[cfg(not(feature = "std"))]
1269 #[tokio::test]
1270 async fn parse_invoice() {
1271 let invoice = Bolt11Invoice::from_str(SAMPLE_INVOICE).unwrap();
1272 let parsed = check_ln_invoice(SAMPLE_INVOICE).await.unwrap();
1273
1274 let amt = invoice.amount_milli_satoshis().map(Amount::from_milli_sats).unwrap().unwrap();
1275
1276 let parsed = match parsed {
1277 PaymentInstructions::FixedAmount(parsed) => parsed,
1278 _ => panic!(),
1279 };
1280
1281 assert_eq!(parsed.methods().len(), 1);
1282 assert_eq!(parsed.ln_payment_amount().unwrap(), amt);
1283 assert_eq!(parsed.onchain_payment_amount(), None);
1284 assert_eq!(parsed.max_amount().unwrap(), amt);
1285 assert_eq!(parsed.recipient_description(), Some(""));
1286 assert!(matches!(&parsed.methods()[0], &PaymentMethod::LightningBolt11(_)));
1287 }
1288
1289 #[cfg(feature = "std")]
1290 #[tokio::test]
1291 async fn parse_invoice() {
1292 assert_eq!(check_ln_invoice(SAMPLE_INVOICE).await, Err(ParseError::InstructionsExpired));
1293 }
1294
1295 #[cfg(not(feature = "std"))]
1296 #[tokio::test]
1297 async fn parse_invoice_with_fallback() {
1298 let invoice = Bolt11Invoice::from_str(SAMPLE_INVOICE_WITH_FALLBACK).unwrap();
1299 let parsed = check_ln_invoice(SAMPLE_INVOICE_WITH_FALLBACK).await.unwrap();
1300
1301 let parsed = match parsed {
1302 PaymentInstructions::FixedAmount(parsed) => parsed,
1303 _ => panic!(),
1304 };
1305
1306 assert_eq!(parsed.methods().len(), 2);
1307 assert_eq!(
1308 parsed.max_amount().unwrap(),
1309 invoice.amount_milli_satoshis().map(Amount::from_milli_sats).unwrap().unwrap(),
1310 );
1311 assert_eq!(
1312 parsed.ln_payment_amount().unwrap(),
1313 invoice.amount_milli_satoshis().map(Amount::from_milli_sats).unwrap().unwrap(),
1314 );
1315 assert_eq!(
1316 parsed.onchain_payment_amount().unwrap(),
1317 invoice.amount_milli_satoshis().map(Amount::from_milli_sats).unwrap().unwrap(),
1318 );
1319
1320 assert_eq!(parsed.recipient_description(), None); let is_bolt11 = |meth: &&PaymentMethod| matches!(meth, &&PaymentMethod::LightningBolt11(_));
1322 assert_eq!(parsed.methods().iter().filter(is_bolt11).count(), 1);
1323 let is_onchain = |meth: &&PaymentMethod| matches!(meth, &&PaymentMethod::OnChain { .. });
1324 assert_eq!(parsed.methods().iter().filter(is_onchain).count(), 1);
1325 }
1326
1327 #[cfg(feature = "std")]
1328 #[tokio::test]
1329 async fn parse_invoice_with_fallback() {
1330 assert_eq!(
1331 check_ln_invoice(SAMPLE_INVOICE_WITH_FALLBACK).await,
1332 Err(ParseError::InstructionsExpired),
1333 );
1334 }
1335
1336 async fn check_ln_offer(offer: &str) -> Result<PaymentInstructions, ParseError> {
1338 assert!(offer.chars().all(|c| c.is_ascii_lowercase() || c.is_digit(10)), "{}", offer);
1339 let resolver = &DummyHrnResolver;
1340 let raw = PaymentInstructions::parse(offer, Network::Signet, resolver, false).await;
1341
1342 let btc_uri = format!("bitcoin:?lno={}", offer);
1343 let uri = PaymentInstructions::parse(&btc_uri, Network::Signet, resolver, false).await;
1344 assert_eq!(raw, uri);
1345
1346 let btc_uri = btc_uri.to_uppercase();
1347 let uri = PaymentInstructions::parse(&btc_uri, Network::Signet, resolver, false).await;
1348 assert_eq!(raw, uri);
1349
1350 let btc_uri = format!("bitcoin:?req-lno={}", offer);
1351 let uri = PaymentInstructions::parse(&btc_uri, Network::Signet, resolver, false).await;
1352 assert_eq!(raw, uri);
1353
1354 let btc_uri = btc_uri.to_uppercase();
1355 let uri = PaymentInstructions::parse(&btc_uri, Network::Signet, resolver, false).await;
1356 assert_eq!(raw, uri);
1357
1358 raw
1359 }
1360
1361 #[tokio::test]
1362 async fn parse_offer() {
1363 let offer = Offer::from_str(SAMPLE_OFFER).unwrap();
1364 let amt_msats = match offer.amount() {
1365 None => None,
1366 Some(offer::Amount::Bitcoin { amount_msats }) => Some(amount_msats),
1367 Some(offer::Amount::Currency { .. }) => panic!(),
1368 };
1369 let parsed = check_ln_offer(SAMPLE_OFFER).await.unwrap();
1370
1371 let parsed = match parsed {
1372 PaymentInstructions::FixedAmount(parsed) => parsed,
1373 _ => panic!(),
1374 };
1375
1376 assert_eq!(parsed.methods().len(), 1);
1377 assert_eq!(
1378 parsed.methods()[0].amount().unwrap(),
1379 amt_msats.map(Amount::from_milli_sats).unwrap().unwrap()
1380 );
1381 assert_eq!(parsed.recipient_description(), Some("faucet"));
1382 assert!(matches!(parsed.methods()[0], PaymentMethod::LightningBolt12(_)));
1383 }
1384
1385 #[tokio::test]
1386 async fn parse_bip_21() {
1387 let parsed =
1388 PaymentInstructions::parse(SAMPLE_BIP21, Network::Bitcoin, &DummyHrnResolver, false)
1389 .await
1390 .unwrap();
1391
1392 assert_eq!(parsed.recipient_description(), None);
1393
1394 let parsed = match parsed {
1395 PaymentInstructions::FixedAmount(parsed) => parsed,
1396 _ => panic!(),
1397 };
1398
1399 let expected_amount = Amount::from_sats(5_000_000_000).unwrap();
1400
1401 assert_eq!(parsed.methods().len(), 1);
1402 assert_eq!(parsed.max_amount(), Some(expected_amount));
1403 assert_eq!(parsed.ln_payment_amount(), None);
1404 assert_eq!(parsed.onchain_payment_amount(), Some(expected_amount));
1405 assert_eq!(parsed.recipient_description(), None);
1406 assert!(matches!(parsed.methods()[0], PaymentMethod::OnChain(_)));
1407 }
1408
1409 #[cfg(not(feature = "std"))]
1410 #[tokio::test]
1411 async fn parse_bip_21_with_invoice() {
1412 let parsed = PaymentInstructions::parse(
1413 SAMPLE_BIP21_WITH_INVOICE,
1414 Network::Bitcoin,
1415 &DummyHrnResolver,
1416 false,
1417 )
1418 .await
1419 .unwrap();
1420
1421 assert_eq!(parsed.recipient_description(), Some("sbddesign: For lunch Tuesday"));
1422
1423 let parsed = match parsed {
1424 PaymentInstructions::FixedAmount(parsed) => parsed,
1425 _ => panic!(),
1426 };
1427
1428 let expected_amount = Amount::from_milli_sats(1_000_000).unwrap();
1429
1430 assert_eq!(parsed.methods().len(), 2);
1431 assert_eq!(parsed.onchain_payment_amount(), Some(expected_amount));
1432 assert_eq!(parsed.ln_payment_amount(), Some(expected_amount));
1433 assert_eq!(parsed.max_amount(), Some(expected_amount));
1434 assert_eq!(parsed.recipient_description(), Some("sbddesign: For lunch Tuesday"));
1435 if let PaymentMethod::OnChain(address) = &parsed.methods()[0] {
1436 assert_eq!(address.to_string(), SAMPLE_BIP21_WITH_INVOICE_ADDR);
1437 } else {
1438 panic!("Missing on-chain (or order changed)");
1439 }
1440 if let PaymentMethod::LightningBolt11(inv) = &parsed.methods()[1] {
1441 assert_eq!(inv.to_string(), SAMPLE_BIP21_WITH_INVOICE_INVOICE);
1442 } else {
1443 panic!("Missing invoice (or order changed)");
1444 }
1445 }
1446
1447 #[cfg(feature = "std")]
1448 #[tokio::test]
1449 async fn parse_bip_21_with_invoice() {
1450 assert_eq!(
1451 PaymentInstructions::parse(
1452 SAMPLE_BIP21_WITH_INVOICE,
1453 Network::Bitcoin,
1454 &DummyHrnResolver,
1455 false,
1456 )
1457 .await,
1458 Err(ParseError::InstructionsExpired),
1459 );
1460 }
1461
1462 #[cfg(not(feature = "std"))]
1463 #[tokio::test]
1464 async fn parse_bip_21_with_invoice_with_label() {
1465 let parsed = PaymentInstructions::parse(
1466 SAMPLE_BIP21_WITH_INVOICE_AND_LABEL,
1467 Network::Signet,
1468 &DummyHrnResolver,
1469 false,
1470 )
1471 .await
1472 .unwrap();
1473
1474 assert_eq!(parsed.recipient_description(), Some("yooo"));
1475
1476 let parsed = match parsed {
1477 PaymentInstructions::FixedAmount(parsed) => parsed,
1478 _ => panic!(),
1479 };
1480
1481 let expected_amount = Amount::from_milli_sats(100_000).unwrap();
1482
1483 assert_eq!(parsed.methods().len(), 2);
1484 assert_eq!(parsed.max_amount(), Some(expected_amount));
1485 assert_eq!(parsed.onchain_payment_amount(), Some(expected_amount));
1486 assert_eq!(parsed.ln_payment_amount(), Some(expected_amount));
1487 assert_eq!(parsed.recipient_description(), Some("yooo"));
1488 assert!(matches!(parsed.methods()[0], PaymentMethod::OnChain(_)));
1489 assert!(matches!(parsed.methods()[1], PaymentMethod::LightningBolt11(_)));
1490 }
1491
1492 #[cfg(feature = "std")]
1493 #[tokio::test]
1494 async fn parse_bip_21_with_invoice_with_label() {
1495 assert_eq!(
1496 PaymentInstructions::parse(
1497 SAMPLE_BIP21_WITH_INVOICE_AND_LABEL,
1498 Network::Signet,
1499 &DummyHrnResolver,
1500 false,
1501 )
1502 .await,
1503 Err(ParseError::InstructionsExpired),
1504 );
1505 }
1506
1507 #[cfg(feature = "http")]
1508 async fn test_lnurl(str: &str) {
1509 let resolver = http_resolver::HTTPHrnResolver::default();
1510 let parsed =
1511 PaymentInstructions::parse(str, Network::Signet, &resolver, false).await.unwrap();
1512
1513 let parsed = match parsed {
1514 PaymentInstructions::ConfigurableAmount(parsed) => parsed,
1515 _ => panic!(),
1516 };
1517
1518 assert_eq!(parsed.methods().count(), 1);
1519 assert_eq!(parsed.min_amt(), Some(Amount::from_milli_sats(1000).unwrap()));
1520 assert_eq!(parsed.max_amt(), Some(Amount::from_milli_sats(11000000000).unwrap()));
1521 }
1522
1523 #[cfg(feature = "http")]
1524 #[tokio::test]
1525 async fn parse_lnurl() {
1526 test_lnurl(SAMPLE_LNURL).await;
1527 test_lnurl(SAMPLE_LNURL_LN_PREFIX).await;
1528 test_lnurl(SAMPLE_LNURL_FALLBACK).await;
1529 test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_AND).await;
1530 test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_HASHTAG).await;
1531 test_lnurl(SAMPLE_LNURL_FALLBACK_WITH_BOTH).await;
1532 }
1533}