Skip to main content

bitcoin_payment_instructions/
lib.rs

1//! These days, there are many possible ways to communicate Bitcoin payment instructions.
2//! This crate attempts to unify them into a simple parser which can read text provided directly by
3//! a payer or via a QR code scan/URI open and convert it into payment instructions.
4//!
5//! This crate doesn't actually help you *pay* these instructions, but provides a unified way to
6//! parse them.
7//!
8//! Payment instructions come in two versions -
9//!  * [`ConfigurableAmountPaymentInstructions`] represent instructions which can be paid with a
10//!    configurable amount, but may require further resolution to convert them into a
11//!    [`FixedAmountPaymentInstructions`] for payment.
12//!  * [`FixedAmountPaymentInstructions`] represent instructions for which the recipient wants a
13//!    specific quantity of funds and needs no further resolution
14//!
15//! In general, you should resolve a string (received either from a QR code scan, a system URI open
16//! call, a "recipient" text box, or a pasted "recipient" instruction) through
17//! [`PaymentInstructions::parse`].
18//!
19//! From there, if you receive a [`PaymentInstructions::FixedAmount`] you should check that you
20//! support at least one of the [`FixedAmountPaymentInstructions::methods`] and request approval
21//! from the wallet owner to complete the payment.
22//!
23//! If you receive a [`PaymentInstructions::ConfigurableAmount`] instead, you should similarly
24//! check that that you support one of the [`ConfigurableAmountPaymentInstructions::methods`] using
25//! [`PossiblyResolvedPaymentMethod::method_type`], then display an amount selection UI to the
26//! wallet owner. Once they've selected an amount, you should proceed with
27//! [`ConfigurableAmountPaymentInstructions::set_amount`] to fetch a finalized
28//! [`FixedAmountPaymentInstructions`] before moving to confirmation and payment.
29
30#![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")] // TODO: Drop once we upgrade to LDK 0.2
61pub 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/// A method which can be used to make a payment
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum PaymentMethod {
77	/// A payment using lightning as described by the given BOLT 11 invoice.
78	LightningBolt11(Bolt11Invoice),
79	/// A payment using lightning as described by the given BOLT 12 offer.
80	LightningBolt12(Offer),
81	/// A payment directly on-chain to the specified address.
82	OnChain(Address),
83	/// A payment using Cashu as described by the given NUT-26 payment request.
84	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
142/// A payment method which may require further resolution once the amount we wish to pay is fixed.
143pub enum PossiblyResolvedPaymentMethod<'a> {
144	/// A payment using lightning as described by a BOLT 11 invoice which will be provided by this
145	/// LNURL-pay endpoint
146	LNURLPay {
147		/// The minimum value the recipient will accept payment for.
148		min_value: Amount,
149		/// The maximum value the recipient will accept payment for.
150		max_value: Amount,
151		/// The URI which must be fetched (once an `amount` parameter is added) to fully resolve
152		/// this into a [`Bolt11Invoice`].
153		callback: &'a str,
154	},
155	/// A payment method which has been fully resolved.
156	Resolved(&'a PaymentMethod),
157}
158
159/// The method that a [`PossiblyResolvedPaymentMethod`] will eventually resolve to.
160///
161/// This is useful to determine if you support the required payment mechanism for a
162/// [`ConfigurableAmountPaymentInstructions`] before you display an amount selector to the wallet
163/// owner.
164pub enum PaymentMethodType {
165	/// The [`PossiblyResolvedPaymentMethod`] will eventually resolve to a
166	/// [`PaymentMethod::LightningBolt11`].
167	LightningBolt11,
168	/// The [`PossiblyResolvedPaymentMethod`] will eventually resolve to a
169	/// [`PaymentMethod::LightningBolt12`].
170	LightningBolt12,
171	/// The [`PossiblyResolvedPaymentMethod`] will eventually resolve to a
172	/// [`PaymentMethod::OnChain`].
173	OnChain,
174	/// The [`PossiblyResolvedPaymentMethod`] will eventually resolve to a
175	/// [`PaymentMethod::Cashu`].
176	Cashu,
177}
178
179impl<'a> PossiblyResolvedPaymentMethod<'a> {
180	/// Fetches the [`PaymentMethodType`] that this payment method will ultimately resolve to.
181	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
205/// Defines common accessors for payment instructions in relation to [`PaymentInstructionsImpl`]
206macro_rules! common_methods {
207	($struct: ty) => {
208		impl $struct {
209			/// A recipient-provided description of the payment instructions.
210			///
211			/// This may be:
212			///  * the `label` or `message` parameter in a BIP 321 bitcoin: URI
213			///  * the `description` field in a lightning BOLT 11 invoice
214			///  * the `description` field in a lightning BOLT 12 offer
215			#[inline]
216			pub fn recipient_description(&self) -> Option<&str> {
217				self.inner().description.as_ref().map(|d| d.as_str())
218			}
219
220			/// Fetches the proof-of-payment callback URI.
221			///
222			/// Once a payment has been completed, the proof-of-payment (hex-encoded payment preimage for a
223			/// lightning BOLT 11 invoice, raw transaction serialized in hex for on-chain payments,
224			/// not-yet-defined for lightning BOLT 12 invoices) must be appended to this URI and the URI
225			/// opened with the default system URI handler.
226			#[inline]
227			pub fn pop_callback(&self) -> Option<&str> {
228				self.inner().pop_callback.as_ref().map(|c| c.as_str())
229			}
230
231			/// Fetches the [`HumanReadableName`] which was resolved, if the resolved payment instructions
232			/// were for a Human Readable Name.
233			#[inline]
234			pub fn human_readable_name(&self) -> &Option<HumanReadableName> {
235				&self.inner().hrn
236			}
237
238			/// Fetches the BIP 353 DNSSEC proof which was used to resolve these payment instructions, if
239			/// they were resolved from a HumanReadable Name using BIP 353.
240			///
241			/// This proof should be included in any PSBT output (as type `PSBT_OUT_DNSSEC_PROOF`)
242			/// generated using these payment instructions.
243			///
244			/// It should also be stored to allow us to later prove that this payment was made to
245			/// [`Self::human_readable_name`].
246			#[inline]
247			pub fn bip_353_dnssec_proof(&self) -> &Option<Vec<u8>> {
248				&self.inner().hrn_proof
249			}
250		}
251	};
252}
253
254/// Parsed payment instructions representing a set of possible ways to pay a fixed quantity to a
255/// recipient, as well as some associated metadata.
256#[derive(Clone, PartialEq, Eq, Debug)]
257pub struct FixedAmountPaymentInstructions {
258	inner: PaymentInstructionsImpl,
259}
260
261impl FixedAmountPaymentInstructions {
262	/// The maximum amount any payment instruction requires payment for.
263	///
264	/// If `None`, the only available payment method requires payment in a currency other than
265	/// sats, requiring currency conversion to determine the amount required.
266	///
267	/// Note that we may allow different [`Self::methods`] to have slightly different amounts (e.g.
268	/// if a recipient wishes to be paid more for on-chain payments to offset their future fees),
269	/// but only up to [`MAX_AMOUNT_DIFFERENCE`].
270	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	/// The amount which the payment instruction requires payment for when paid over lightning.
278	///
279	/// We require that all lightning payment methods in payment instructions require an identical
280	/// amount for payment, and thus if this method returns `None` it indicates either:
281	///  * no lightning payment instructions exist,
282	///  * the only lightning payment instructions are for a BOLT 12 offer denominated in a
283	///    non-Bitcoin currency.
284	///
285	/// Note that if this object was built by resolving a [`ConfigurableAmountPaymentInstructions`]
286	/// with [`set_amount`] on a lightning BOLT 11 or BOLT 12 invoice-containing instruction, this
287	/// will return `Some` but the [`Self::methods`] with [`PaymentMethod::LightningBolt11`] or
288	/// [`PaymentMethod::LightningBolt12`] may still contain instructions without amounts.
289	///
290	/// [`set_amount`]: ConfigurableAmountPaymentInstructions::set_amount
291	pub fn ln_payment_amount(&self) -> Option<Amount> {
292		self.inner.ln_amt
293	}
294
295	/// The amount which the payment instruction requires payment for when paid via Cashu.
296	///
297	/// We require that all Cashu payment methods in payment instructions require an identical
298	/// amount for payment.
299	pub fn cashu_payment_amount(&self) -> Option<Amount> {
300		self.inner.cashu_amt
301	}
302
303	/// The amount which the payment instruction requires payment for when paid on-chain.
304	///
305	/// Will return `None` if no on-chain payment instructions are available.
306	///
307	/// There is no way to encode different payment amounts for multiple on-chain formats
308	/// currently, and as such all on-chain [`PaymentMethod`]s are for the same amount.
309	pub fn onchain_payment_amount(&self) -> Option<Amount> {
310		self.inner.onchain_amt
311	}
312
313	/// The list of [`PaymentMethod`]s.
314	#[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/// Parsed payment instructions representing a set of possible ways to pay a configurable quantity
327/// of Bitcoin, as well as some associated metadata.
328#[derive(Clone, PartialEq, Eq, Debug)]
329pub struct ConfigurableAmountPaymentInstructions {
330	inner: PaymentInstructionsImpl,
331}
332
333impl ConfigurableAmountPaymentInstructions {
334	/// The minimum amount which the recipient will accept payment for, if provided as a part of
335	/// the payment instructions.
336	pub fn min_amt(&self) -> Option<Amount> {
337		self.inner.lnurl.as_ref().map(|(_, _, a, _)| *a)
338	}
339
340	/// The minimum amount which the recipient will accept payment for, if provided as a part of
341	/// the payment instructions.
342	pub fn max_amt(&self) -> Option<Amount> {
343		self.inner.lnurl.as_ref().map(|(_, _, _, a)| *a)
344	}
345
346	/// The supported list of [`PossiblyResolvedPaymentMethod`].
347	///
348	/// See [`PossiblyResolvedPaymentMethod::method_type`] for the specific payment protocol which
349	/// each payment method will ultimately resolve to.
350	#[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	/// Resolve the configurable amount to a fixed amount and create a
359	/// [`FixedAmountPaymentInstructions`].
360	///
361	/// May resolve LNURL-Pay instructions that were created from an LN-Address Human Readable
362	/// Name into a lightning [`Bolt11Invoice`].
363	///
364	/// Note that for lightning BOLT 11 or BOLT 12 instructions, we cannot modify the invoice/offer
365	/// itself and thus cannot set a specific amount on the [`PaymentMethod::LightningBolt11`] or
366	/// [`PaymentMethod::LightningBolt12`] inner fields themselves. Still,
367	/// [`FixedAmountPaymentInstructions::ln_payment_amount`] will return the value provided in
368	/// `amount`.
369	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/// Parsed payment instructions representing a set of possible ways to pay, as well as some
413/// associated metadata.
414///
415/// Currently we can resolve the following strings into payment instructions:
416///  * BIP 321 bitcoin: URIs
417///  * Lightning BOLT 11 invoices (optionally with the lightning: URI prefix)
418///  * Lightning BOLT 12 offers
419///  * On-chain addresses
420///  * BIP 353 human-readable names in the name@domain format.
421///  * LN-Address human-readable names in the name@domain format.
422#[derive(Clone, PartialEq, Eq, Debug)]
423pub enum PaymentInstructions {
424	/// The payment instructions support a variable amount which must be selected prior to payment.
425	///
426	/// In general, you should first check that you support some of the payment methods by calling
427	/// [`PossiblyResolvedPaymentMethod::method_type`] on each method in
428	/// [`ConfigurableAmountPaymentInstructions::methods`], then request the intended amount from
429	/// the wallet owner and build the final instructions using
430	/// [`ConfigurableAmountPaymentInstructions::set_amount`].
431	ConfigurableAmount(ConfigurableAmountPaymentInstructions),
432	/// The payment instructions support only payment for specific amount(s) given by
433	/// [`FixedAmountPaymentInstructions::ln_payment_amount`] and
434	/// [`FixedAmountPaymentInstructions::onchain_payment_amount`] (which are within
435	/// [`MAX_AMOUNT_DIFFERENCE`] of each other).
436	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
450/// The maximum amount requested that we will allow individual payment methods to differ in
451/// satoshis.
452///
453/// If any [`PaymentMethod`] is for an amount different by more than this amount from another
454/// [`PaymentMethod`], we will consider it a [`ParseError::InconsistentInstructions`].
455pub const MAX_AMOUNT_DIFFERENCE: Amount = Amount::from_sats_panicy(100);
456
457/// An error when parsing payment instructions into [`PaymentInstructions`].
458#[derive(Debug)]
459#[cfg_attr(test, derive(PartialEq))]
460pub enum ParseError {
461	/// An invalid lightning BOLT 11 invoice was encountered
462	InvalidBolt11(ParseOrSemanticError),
463	/// An invalid lightning BOLT 12 offer was encountered
464	InvalidBolt12(Bolt12ParseError),
465	/// An invalid on-chain address was encountered
466	InvalidOnChain(address::ParseError),
467	/// An invalid Cashu payment request was encountered
468	InvalidCashu(cashu::Error),
469	/// An invalid lnurl was encountered
470	InvalidLnurl(&'static str),
471	/// The payment instructions encoded instructions for a network other than the one specified.
472	WrongNetwork,
473	/// Different parts of the payment instructions were inconsistent.
474	///
475	/// A developer-readable error string is provided, though you may or may not wish to provide
476	/// this directly to users.
477	InconsistentInstructions(&'static str),
478	/// The instructions were invalid due to a semantic error.
479	///
480	/// A developer-readable error string is provided, though you may or may not wish to provide
481	/// this directly to users.
482	InvalidInstructions(&'static str),
483	/// The payment instructions did not appear to match any known form of payment instructions.
484	UnknownPaymentInstructions,
485	/// The BIP 321 bitcoin: URI included unknown required parameter(s)
486	UnknownRequiredParameter,
487	/// The call to [`HrnResolver::resolve_hrn`] failed with the contained error.
488	HrnResolutionError(&'static str),
489	/// The payment instructions have expired and are no longer payable.
490	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
572// What str.split_once() should do...
573fn 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							// `bc`/`tb` key-values must only include bech32/bech32m strings with
631							// HRP "bc"/"tb" (i.e. mainnet/testnet Segwit addresses).
632							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					// We handle this in the second loop below
702				} else if k.eq_ignore_ascii_case("label") || k.eq_ignore_ascii_case("req-label") {
703					// We handle this in the second loop below
704				} else if k.eq_ignore_ascii_case("message") || k.eq_ignore_ascii_case("req-message")
705				{
706					// We handle this in the second loop below
707				} 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") // lol
728							|| 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			// No parameters were provided, so we just have the on-chain address in the URI body.
875			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		// Though there is no specification, lightning: URIs generally only include BOLT 11
896		// invoices.
897		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	/// Resolves a string into [`PaymentInstructions`].
1017	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			// If it looks like a BIP 353 URI, jump straight to parsing it and ignore any LNURL
1056			// overrides.
1057			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			// first try to decode as a bech32-encoded lnurl, if that fails, try to drop a
1061			// trailing `&` and decode again, this could a http query param
1062			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		// Max amount should pick up the cashu amount
1166		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			// ok
1195		} 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	// Test a handful of ways a lightning invoice might be communicated
1232	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); // no description for a description hash
1321		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	// Test a handful of ways a lightning offer might be communicated
1337	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}