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),
#[error("Returned invoice does not contain an amount")]
InvoiceAmountUndefined,
#[error(
"Returned invoice amount {actual} msat does not match requested amount {expected} msat"
)]
IncorrectInvoiceAmount { actual: u64, expected: u64 },
}
#[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)?;
let invoice =
Bolt11Invoice::from_str(&pr).map_err(|e| Error::InvoiceParse(e.to_string()))?;
let invoice_amount_msat = invoice
.amount_milli_satoshis()
.ok_or(Error::InvoiceAmountUndefined)?;
if invoice_amount_msat != amount_msat_u64 {
return Err(Error::IncorrectInvoiceAmount {
actual: invoice_amount_msat,
expected: amount_msat_u64,
});
}
Ok(invoice)
}
}
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 std::sync::Arc;
use super::*;
use crate::wallet::test_utils::MockMintConnector;
const INVOICE_100_SATS: &str = "lnbc1u1p53kkd9pp5ve8pd9zr60yjyvs6tn77mndavzrl5lwd2gx5hk934f6q8jwguzgsdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5482y73fxmlvg4t66nupdaph93h7dcmfsg2ud72wajf0cpk3a96rq9qxpqysgqujexd0l89u5dutn8hxnsec0c7jrt8wz0z67rut0eah0g7p6zhycn2vff0ts5vwn2h93kx8zzqy3tzu4gfhkya2zpdmqelg0ceqnjztcqma65pr";
#[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());
}
#[tokio::test]
async fn test_request_invoice_accepts_matching_invoice_amount() {
let connector = Arc::new(MockMintConnector::new());
connector.set_lnurl_pay_request_response(Ok(LnurlPayResponse {
callback: "https://example.com/callback".to_string(),
min_sendable: 1,
max_sendable: 1_000_000,
metadata: "[]".to_string(),
tag: Some("payRequest".to_string()),
reason: None,
}));
connector.set_lnurl_invoice_response(Ok(LnurlPayInvoiceResponse {
pr: Some(INVOICE_100_SATS.to_string()),
success_action: None,
routes: None,
reason: None,
}));
let address = LightningAddress::from_str("alice@example.com").expect("valid address");
let invoice = address
.request_invoice(
&(connector as Arc<dyn crate::wallet::MintConnector + Send + Sync>),
Amount::from(100_000_u64),
)
.await
.expect("matching amount should succeed");
assert_eq!(invoice.amount_milli_satoshis(), Some(100_000));
}
}