use std::str::FromStr;
use std::sync::Arc;
use lightning_invoice::Bolt11Invoice;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::instrument;
use url::Url;
use crate::wallet::MintConnector;
use crate::Amount;
#[derive(Debug, Error)]
pub enum Error {
#[error("Invalid Lightning address format: {0}")]
InvalidFormat(String),
#[error("Invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("Failed to fetch pay request data: {0}")]
FetchPayRequest(#[from] crate::Error),
#[error("Lightning address service error: {0}")]
Service(String),
#[error("Amount {amount} msat is below minimum {min} msat")]
AmountBelowMinimum { amount: u64, min: u64 },
#[error("Amount {amount} msat is above maximum {max} msat")]
AmountAboveMaximum { amount: u64, max: u64 },
#[error("No invoice in response")]
NoInvoice,
#[error("Failed to parse invoice: {0}")]
InvoiceParse(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LightningAddress {
user: String,
domain: String,
}
impl LightningAddress {
fn to_url(&self) -> Result<Url, Error> {
let url_str = format!("https://{}/.well-known/lnurlp/{}", self.domain, self.user);
Ok(Url::parse(&url_str)?)
}
#[instrument(skip(client))]
async fn fetch_pay_request_data(
&self,
client: &Arc<dyn MintConnector + Send + Sync>,
) -> Result<LnurlPayResponse, Error> {
let url = self.to_url()?;
tracing::debug!("Fetching Lightning address pay data from: {}", url);
let lnurl_response = client.fetch_lnurl_pay_request(url.as_str()).await?;
if let Some(ref reason) = lnurl_response.reason {
return Err(Error::Service(reason.clone()));
}
Ok(lnurl_response)
}
#[instrument(skip(client))]
pub(crate) async fn request_invoice(
&self,
client: &Arc<dyn MintConnector + Send + Sync>,
amount_msat: Amount,
) -> Result<Bolt11Invoice, Error> {
let pay_data = self.fetch_pay_request_data(client).await?;
let amount_msat_u64: u64 = amount_msat.into();
if amount_msat_u64 < pay_data.min_sendable {
return Err(Error::AmountBelowMinimum {
amount: amount_msat_u64,
min: pay_data.min_sendable,
});
}
if amount_msat_u64 > pay_data.max_sendable {
return Err(Error::AmountAboveMaximum {
amount: amount_msat_u64,
max: pay_data.max_sendable,
});
}
let mut callback_url = Url::parse(&pay_data.callback)?;
callback_url
.query_pairs_mut()
.append_pair("amount", &amount_msat_u64.to_string());
tracing::debug!("Requesting invoice from callback: {}", callback_url);
let invoice_response = client.fetch_lnurl_invoice(callback_url.as_str()).await?;
if let Some(ref reason) = invoice_response.reason {
return Err(Error::Service(reason.clone()));
}
let pr = invoice_response.pr.ok_or(Error::NoInvoice)?;
Bolt11Invoice::from_str(&pr).map_err(|e| Error::InvoiceParse(e.to_string()))
}
}
impl FromStr for LightningAddress {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
if !trimmed.contains('@') {
return Err(Error::InvalidFormat("must contain '@'".to_string()));
}
let parts: Vec<&str> = trimmed.split('@').collect();
if parts.len() != 2 {
return Err(Error::InvalidFormat("must be user@domain".to_string()));
}
let user = parts[0].trim();
let domain = parts[1].trim();
if user.is_empty() || domain.is_empty() {
return Err(Error::InvalidFormat(
"user and domain must not be empty".to_string(),
));
}
Ok(LightningAddress {
user: user.to_string(),
domain: domain.to_string(),
})
}
}
impl std::fmt::Display for LightningAddress {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.user, self.domain)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LnurlPayResponse {
pub callback: String,
#[serde(rename = "minSendable")]
pub min_sendable: u64,
#[serde(rename = "maxSendable")]
pub max_sendable: u64,
pub metadata: String,
pub tag: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LnurlPayInvoiceResponse {
pub pr: Option<String>,
pub success_action: Option<serde_json::Value>,
pub routes: Option<Vec<serde_json::Value>>,
pub reason: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lightning_address_parsing() {
let addr = LightningAddress::from_str("satoshi@bitcoin.org").unwrap();
assert_eq!(addr.user, "satoshi");
assert_eq!(addr.domain, "bitcoin.org");
assert_eq!(addr.to_string(), "satoshi@bitcoin.org");
}
#[test]
fn test_lightning_address_to_url() {
let addr = LightningAddress {
user: "alice".to_string(),
domain: "example.com".to_string(),
};
let url = addr.to_url().unwrap();
assert_eq!(url.as_str(), "https://example.com/.well-known/lnurlp/alice");
}
#[test]
fn test_invalid_lightning_address() {
assert!(LightningAddress::from_str("invalid").is_err());
assert!(LightningAddress::from_str("@example.com").is_err());
assert!(LightningAddress::from_str("user@").is_err());
assert!(LightningAddress::from_str("user").is_err());
}
}