fedimint_lnurl/
lib.rs

1use bech32::{Bech32, Hrp};
2use lightning_invoice::Bolt11Invoice;
3use serde::{Deserialize, Serialize};
4use serde_with::hex::Hex;
5use serde_with::serde_as;
6use url::Url;
7
8/// Generic LNURL response wrapper that handles the status field.
9/// All LNURL responses follow the {"status": "OK"|"ERROR", ...} pattern.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "status")]
12pub enum LnurlResponse<T> {
13    #[serde(rename = "OK")]
14    Ok(T),
15    #[serde(rename = "ERROR")]
16    Error { reason: String },
17}
18
19impl<T> LnurlResponse<T> {
20    pub fn into_result(self) -> Result<T, String> {
21        match self {
22            Self::Ok(data) => Ok(data),
23            Self::Error { reason } => Err(reason),
24        }
25    }
26}
27
28/// Decode a bech32-encoded LNURL string to a URL
29pub fn parse_lnurl(s: &str) -> Option<Url> {
30    let (hrp, data) = bech32::decode(s).ok()?;
31
32    if hrp.as_str() != "lnurl" {
33        return None;
34    }
35
36    String::from_utf8(data)
37        .ok()
38        .and_then(|s| Url::parse(&s).ok())
39}
40
41/// Encode a URL as a bech32 LNURL string
42pub fn encode_lnurl(url: &Url) -> String {
43    bech32::encode::<Bech32>(
44        Hrp::parse("lnurl").expect("valid hrp"),
45        url.as_str().as_bytes(),
46    )
47    .expect("encoding succeeds")
48}
49
50/// Parse a lightning address (user@domain) to its LNURL-pay endpoint URL
51pub fn parse_address(s: &str) -> Option<Url> {
52    let (user, domain) = s.split_once('@')?;
53
54    if user.is_empty() || domain.is_empty() {
55        return None;
56    }
57
58    Url::parse(&format!("https://{domain}/.well-known/lnurlp/{user}")).ok()
59}
60
61pub fn pay_request_tag() -> String {
62    "payRequest".to_string()
63}
64
65/// LNURL-pay response (LUD-06)
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct PayResponse {
69    pub tag: String,
70    pub callback: String,
71    pub metadata: String,
72    pub min_sendable: u64,
73    pub max_sendable: u64,
74}
75
76/// Response when requesting an invoice from LNURL-pay callback
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct InvoiceResponse {
79    /// The BOLT11 invoice
80    pub pr: Bolt11Invoice,
81    /// LUD-21 verify URL
82    pub verify: Option<String>,
83}
84
85/// LUD-21 verify response
86#[serde_as]
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct VerifyResponse {
89    pub settled: bool,
90    #[serde_as(as = "Option<Hex>")]
91    pub preimage: Option<[u8; 32]>,
92}
93
94/// Fetch and parse an LNURL-pay response
95pub async fn request(url: &Url) -> Result<PayResponse, String> {
96    reqwest::get(url.clone())
97        .await
98        .map_err(|e| e.to_string())?
99        .json::<LnurlResponse<PayResponse>>()
100        .await
101        .map_err(|e| e.to_string())?
102        .into_result()
103}
104
105/// Fetch an invoice from an LNURL-pay callback
106pub async fn get_invoice(
107    response: &PayResponse,
108    amount_msat: u64,
109) -> Result<InvoiceResponse, String> {
110    if amount_msat < response.min_sendable {
111        return Err("Amount too low".to_string());
112    }
113
114    if amount_msat > response.max_sendable {
115        return Err("Amount too high".to_string());
116    }
117
118    let callback_url = format!("{}?amount={}", response.callback, amount_msat);
119
120    reqwest::get(callback_url)
121        .await
122        .map_err(|e| e.to_string())?
123        .json::<LnurlResponse<InvoiceResponse>>()
124        .await
125        .map_err(|e| e.to_string())?
126        .into_result()
127}
128
129/// Verify a payment using LUD-21
130pub async fn verify_invoice(url: &str) -> Result<VerifyResponse, String> {
131    reqwest::get(url)
132        .await
133        .map_err(|e| e.to_string())?
134        .json::<LnurlResponse<VerifyResponse>>()
135        .await
136        .map_err(|e| e.to_string())?
137        .into_result()
138}