Skip to main content

lexe_payment_uri/
lib.rs

1//! Payment URI resolution.
2//!
3//! For core types and parsing, see [`lexe_payment_uri_core`].
4
5/// BIP353 resolution.
6pub mod bip353;
7/// LNURL-pay and Lightning Address resolution.
8pub mod lnurl;
9
10use anyhow::{Context, anyhow, ensure};
11use lexe_common::ln::network::Network;
12pub use lexe_payment_uri_core::*;
13
14/// Resolve a `PaymentUri` into a single, "best" [`PaymentMethod`].
15//
16// phlip9: this impl is currently pretty dumb and just unconditionally
17// returns the first (valid) BOLT11 invoice it finds, o/w onchain. It's not
18// hard to imagine a better strategy, like using our current
19// liquidity/balance to decide onchain vs LN, or returning all methods and
20// giving the user a choice. This'll also need to be async in the future, as
21// we'll need to fetch invoices from any LNURL endpoints we come across.
22pub async fn resolve_best(
23    bip353_client: &bip353::Bip353Client,
24    lnurl_client: &lnurl::LnurlClient,
25    network: Network,
26    payment_uri: PaymentUri,
27) -> anyhow::Result<PaymentMethod> {
28    // A single scanned/opened PaymentUri can contain multiple different payment
29    // methods (e.g., a LN BOLT11 invoice + an onchain fallback address).
30    let mut payment_methods =
31        resolve_payment_methods(bip353_client, lnurl_client, payment_uri)
32            .await
33            .context("Failed to resolve payment URI into payment methods")?;
34
35    // Filter out all methods that aren't valid for our current network
36    // (e.g., ignore all testnet addresses when we're cfg'd for mainnet).
37    payment_methods.retain(|method| method.supports_network(network));
38    ensure!(
39        !payment_methods.is_empty(),
40        "Payment code is not valid for {network}"
41    );
42
43    // Pick the most preferable payment method.
44    let best = payment_methods
45        .into_iter()
46        .max_by_key(|x| match x {
47            PaymentMethod::Invoice(_) => 40,
48            PaymentMethod::Offer(_) => 30,
49            PaymentMethod::LnurlPayRequest(_) => 20,
50            PaymentMethod::Onchain(o) => 10 + o.relative_priority(),
51        })
52        .expect("We just checked there's at least one method");
53
54    Ok(best)
55}
56
57/// Resolve the [`PaymentUri`] into its component [`PaymentMethod`]s.
58async fn resolve_payment_methods(
59    bip353_client: &bip353::Bip353Client,
60    lnurl_client: &lnurl::LnurlClient,
61    payment_uri: PaymentUri,
62) -> anyhow::Result<Vec<PaymentMethod>> {
63    let payment_methods = match payment_uri {
64        PaymentUri::Bip321Uri(bip321) => bip321.flatten(),
65
66        PaymentUri::LightningUri(lnuri) => lnuri.flatten(),
67
68        PaymentUri::Invoice(invoice) =>
69            lexe_payment_uri_core::helpers::flatten_invoice(invoice),
70
71        PaymentUri::Offer(offer) => vec![PaymentMethod::Offer(
72            OfferWithAmount::no_bip321_amount(offer),
73        )],
74
75        PaymentUri::Address(address) =>
76            vec![PaymentMethod::Onchain(Onchain::from(address))],
77
78        PaymentUri::EmailLikeAddress(email_like) => {
79            let mut methods = Vec::with_capacity(3);
80            let mut errors = Vec::with_capacity(2);
81
82            // Try resolving BIP353 if this is a valid BIP353 address.
83            if let Some(bip353_fqdn) = email_like.bip353_fqdn {
84                let bip353_result = bip353_client
85                    .resolve_bip353_fqdn(bip353_fqdn)
86                    .await
87                    .context("Failed to resolve BIP353 address");
88                match bip353_result {
89                    Ok(bip353_methods) => {
90                        // Early return if we found any non-onchain methods,
91                        // as we can pay those immediately.
92                        // NOTE: Revisit if/when we support paying via ecash?
93                        if bip353_methods.iter().any(|m| !m.is_onchain()) {
94                            return Ok(bip353_methods);
95                        } else {
96                            methods.extend(bip353_methods);
97                        }
98                    }
99                    Err(e) => errors.push(format!("{e:#}")),
100                }
101            }
102
103            // Always try resolving Lightning Address
104            let ln_address_result = lnurl_client
105                .get_pay_request(&email_like.lightning_address_url)
106                .await
107                .context("Failed to resolve Lightning Address url");
108            match ln_address_result {
109                Ok(pay_request) =>
110                    methods.push(PaymentMethod::LnurlPayRequest(pay_request)),
111                Err(e) => errors.push(format!("{e:#}")),
112            }
113
114            // Consider it a success if we resolved at least one method, since
115            // receivers may support only one of BIP353 or Lightning Address.
116            // Otherwise, return a combined error.
117            if !methods.is_empty() {
118                methods
119            } else {
120                debug_assert!(!errors.is_empty());
121                let joined_errs = errors.join("; ");
122                return Err(anyhow!("{joined_errs}"));
123            }
124        }
125
126        PaymentUri::Lnurl(lnurl) => {
127            let pay_request = lnurl_client
128                .get_pay_request(&lnurl.http_url)
129                .await
130                .context("Failed to resolve LNURL-pay url")?;
131
132            vec![PaymentMethod::LnurlPayRequest(pay_request)]
133        }
134    };
135
136    Ok(payment_methods)
137}
138
139#[cfg(test)]
140mod test {
141    use std::time::Duration;
142
143    use lexe_common::{env::DeployEnv, ln::network::Network};
144    use lexe_std::Apply;
145    use tracing::info;
146
147    use super::*;
148
149    /// Live test that resolves Matt's BIP353 address using resolve_best.
150    ///
151    /// As of 2025-10-11, "matt@mattcorallo.com" doesn't support Lightning
152    /// Address--Lightning Address resolution is expected to fail. This is a
153    /// common case whenever we resolve email-like addresses that start with the
154    /// `₿` prefix. This tests that Lightning Address resolution fails quickly
155    /// in this case rather than always adding a delay equivalent to
156    /// [`lnurl::LNURL_HTTP_TIMEOUT`].
157    ///
158    /// ```bash
159    /// $ RUST_LOG=debug just cargo-test -p lexe-payment-uri test_resolve_best_bluematt -- --ignored --nocapture
160    /// ```
161    #[tokio::test]
162    #[ignore]
163    async fn test_resolve_best_bluematt() {
164        /// Both BIP353 pass + Lightning Address fail should happen within this.
165        const RESOLVE_BEST_TIMEOUT: Duration = Duration::from_secs(5);
166        lexe_std::const_assert!(
167            lnurl::LNURL_HTTP_TIMEOUT.as_secs()
168                > RESOLVE_BEST_TIMEOUT.as_secs()
169        );
170
171        lexe_logger::init_for_testing();
172
173        let payment_uri = PaymentUri::parse("matt@mattcorallo.com").unwrap();
174        info!("Resolving best payment method for matt@mattcorallo.com");
175
176        let bip353_client =
177            bip353::Bip353Client::new(bip353::GOOGLE_DOH_ENDPOINT).unwrap();
178        let lnurl_client = lnurl::LnurlClient::new(DeployEnv::Prod).unwrap();
179
180        let payment_method = resolve_best(
181            &bip353_client,
182            &lnurl_client,
183            Network::Mainnet,
184            payment_uri,
185        )
186        .apply(|fut| tokio::time::timeout(RESOLVE_BEST_TIMEOUT, fut))
187        .await
188        .expect("Timed out")
189        .unwrap();
190
191        // Payment methods are Offer and Onchain, but Offer is higher priority.
192        assert!(matches!(payment_method, PaymentMethod::Offer(_)));
193        assert!(payment_method.supports_network(Network::Mainnet));
194
195        info!("Successfully resolved BlueMatt's payment methods");
196    }
197}