use crate::config::settings::Settings;
use crate::lnurl::ln_exists;
use chrono::prelude::*;
use chrono::TimeDelta;
use lightning_invoice::{Bolt11Invoice, SignedRawBolt11Invoice};
use lnurl::lightning_address::LightningAddress;
use lnurl::lnurl::LnUrl;
use mostro_core::prelude::*;
use std::str::FromStr;
pub fn decode_invoice(payment_request: &str) -> Result<Bolt11Invoice, MostroError> {
let invoice = Bolt11Invoice::from_str(payment_request)
.map_err(|_| MostroInternalErr(ServiceError::InvoiceInvalidError))?;
Ok(invoice)
}
async fn validate_lightning_address(payment_request: &str) -> Result<(), MostroError> {
if ln_exists(payment_request).await.is_err() {
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
}
Ok(())
}
async fn validate_bolt11_invoice(
payment_request: &str,
amount: Option<u64>,
fee: Option<u64>,
) -> Result<(), MostroError> {
let invoice = decode_invoice(payment_request)?;
let mostro_settings = Settings::get_mostro();
let ln_settings = Settings::get_ln();
let amount_sat = invoice.amount_milli_satoshis().unwrap_or(0) / 1000;
let fee = fee.unwrap_or(0);
if let Some(amt) = amount {
if let Some(expected_sats_amount) = amt.checked_sub(fee) {
if amount_sat != expected_sats_amount && amount_sat != 0 {
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
}
} else {
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
}
}
if amount_sat > 0 && amount_sat < mostro_settings.min_payment_amount as u64 {
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
}
if invoice.is_expired() {
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
}
let parsed = payment_request
.parse::<SignedRawBolt11Invoice>()
.map_err(|_| MostroInternalErr(ServiceError::InvoiceInvalidError))?;
let (parsed_invoice, _, _) = parsed.into_parts();
let expiration_window = ln_settings.invoice_expiration_window as i64;
let latest_date = Utc::now()
+ TimeDelta::try_seconds(expiration_window).expect("wrong seconds timeout value");
let latest_date = latest_date.timestamp() as u64;
let expires_at =
invoice.expiry_time().as_secs() + parsed_invoice.data.timestamp.as_unix_timestamp();
if expires_at < latest_date {
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
}
Ok(())
}
pub async fn is_valid_invoice(
payment_request: String,
amount: Option<u64>,
fee: Option<u64>,
) -> Result<(), MostroError> {
if LightningAddress::from_str(&payment_request).is_ok()
|| LnUrl::from_str(&payment_request).is_ok()
{
return validate_lightning_address(&payment_request).await;
}
validate_bolt11_invoice(&payment_request, amount, fee).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MOSTRO_CONFIG;
use axum::{http::StatusCode, routing::get, Json, Router};
use mostro_core::error::{MostroError::MostroInternalErr, ServiceError};
use serde_json::json;
use tokio::net::TcpListener;
use toml;
fn init_settings_test() {
let config_tpl = include_bytes!("../../settings.tpl.toml");
let config_tpl =
std::str::from_utf8(config_tpl).expect("Invalid UTF-8 in template config file");
let test_settings: Settings =
toml::from_str(config_tpl).expect("Failed to parse template config file");
MOSTRO_CONFIG.get_or_init(|| test_settings);
}
async fn handle_request() -> (StatusCode, Json<serde_json::Value>) {
let response = json!({
"status": "OK",
"tag": "payRequest",
"callback": "http://localhost:8080/callback",
"minSendable": 1000,
"maxSendable": 10000000,
"metadata": "[[\"text/plain\",\"Test payment\"]]",
"pr": "lnbcrt500u1p3l8zyapp5nc0ctxjt98xq9tgdgk9m8fepnp0kv6mnj6a83mfsannw46awdp4sdqqcqzpgxqyz5vqsp5a3axmz77s5vafmheq56uh49rmy59r9a3d0dm0220l8lzdp5jrtxs9qyyssqu0ft47j0r4lu997zuqgf92y8mppatwgzhrl0hzte7mzmwrqzf2238ylch82ehhv7pfcq6qcyu070dg85vu55het2edyljuezvcw5pzgqfncf3d"
});
(StatusCode::OK, Json(response))
}
async fn start_test_server() -> (String, tokio::task::JoinHandle<()>) {
let app = Router::new()
.route("/.well-known/lnurlp/MostroP2P", get(handle_request))
.route(
"/.well-known/lnurlp/MostroP2Ptestlnurl",
get(handle_request),
)
.layer(tower_http::cors::CorsLayer::permissive());
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
let addr = listener.local_addr().unwrap();
let port = addr.port();
let server = axum::serve(listener, app);
let handle = tokio::spawn(async move {
server.await.unwrap();
});
let url = format!(
"http://localhost:{}/.well-known/lnurlp/MostroP2Ptestlnurl",
port
);
let lnurl_obj = LnUrl { url: url.clone() };
let lnurl = lnurl_obj.encode();
(lnurl, handle)
}
#[tokio::test]
async fn test_wrong_amount_invoice() {
init_settings_test();
let payment_request = "lnbcrt500u1p3lzwdzpp5t9kgwgwd07y2lrwdscdnkqu4scrcgpm5pt9uwx0rxn5rxawlxlvqdqqcqzpgxqyz5vqsp5a6k7syfxeg8jy63rteywwjla5rrg2pvhedx8ajr2ltm4seydhsqq9qyyssq0n2uwlumsx4d0mtjm8tp7jw3y4da6p6z9gyyjac0d9xugf72lhh4snxpugek6n83geafue9ndgrhuhzk98xcecu2t3z56ut35mkammsqscqp0n".to_string();
let wrong_amount_err = is_valid_invoice(payment_request, Some(23), None);
assert_eq!(
Err(MostroInternalErr(ServiceError::InvoiceInvalidError)),
wrong_amount_err.await
);
}
#[tokio::test]
async fn test_is_expired_invoice() {
init_settings_test();
let payment_request = "lnbcrt500u1p3lzwdzpp5t9kgwgwd07y2lrwdscdnkqu4scrcgpm5pt9uwx0rxn5rxawlxlvqdqqcqzpgxqyz5vqsp5a6k7syfxeg8jy63rteywwjla5rrg2pvhedx8ajr2ltm4seydhsqq9qyyssq0n2uwlumsx4d0mtjm8tp7jw3y4da6p6z9gyyjac0d9xugf72lhh4snxpugek6n83geafue9ndgrhuhzk98xcecu2t3z56ut35mkammsqscqp0n".to_string();
let expired_err = is_valid_invoice(payment_request, None, None);
assert_eq!(
Err(MostroInternalErr(ServiceError::InvoiceInvalidError)),
expired_err.await
);
}
#[tokio::test]
async fn test_zero_amount_invoice() {
init_settings_test();
let payment_request = "lnbc01p5dzna7pp5e23a62fcx6mcyhn9cqppln52ge4xpv0p8fv44a5jewtdypvuj7rqcqzyssp5xwcx4hn7sahsaq3y5ln8yt3qwsxqwtzwac0d32s825rcnp4yps5q9q7sqqqqqqqqqqqqqqqqqqqsqqqqqysgqdqqmqz9gxqyjw5qrzjqwryaup9lh50kkranzgcdnn2fgvx390wgj5jd07rwr3vxeje0glclludlw6z8nzdzcqqqqlgqqqqqeqqjqaq8mpxmhte2h3t0pnw7ey6hu5wvzd5ftm236jwf4whnddvwggw8ka343d9ecq93camv7lju889e4etjfc2mguvdcdkfqc00alc4lfusq7x0jsx".to_string();
let invoice = decode_invoice(&payment_request).expect("failed to decode invoice");
assert_eq!(invoice.amount_milli_satoshis().unwrap(), 0);
}
#[tokio::test]
async fn test_min_amount_invoice() {
init_settings_test();
let payment_request = "lnbcrt10n1pjwqagdpp5qwa89czezks35s73fkjspxdssh7h4mmfs4643ey7fgxlng4d3jxqdqqcqzpgxqyz5vqsp5jjlmj6hlq0zxsg5t7n6h6a95ux3ej2w3w2csvdgcpndyvut3aaqs9qyyssqg6py7mmjlcgrscvvq4x3c6kr6f6reqanwkk7rjajm4wepggh4lnku3msrjt3045l0fsl4trh3ctg8ew756wq86mz72mguusey7m0a5qq83t8n6".to_string();
let min_amount_err = is_valid_invoice(payment_request, None, None);
assert_eq!(
Err(MostroInternalErr(ServiceError::InvoiceInvalidError)),
min_amount_err.await
);
}
#[tokio::test]
async fn test_lnurl_validation_with_test_server() {
init_settings_test();
let (lnurl, server_handle) = start_test_server().await;
let result = is_valid_invoice(lnurl.clone(), None, None).await;
assert!(result.is_ok(), "Basic LNURL validation should succeed");
let result = is_valid_invoice(lnurl.clone(), Some(5000), None).await;
assert!(
result.is_ok(),
"LNURL validation with valid amount should succeed"
);
let valid_address = "MostroP2P@localhost".to_string();
let result = is_valid_invoice(valid_address, None, None).await;
assert!(
result.is_ok(),
"Valid Lightning address should pass validation"
);
let invalid_address = "nonexistent@localhost".to_string();
let result = is_valid_invoice(invalid_address, None, None).await;
assert!(
result.is_err(),
"Invalid Lightning address should fail validation"
);
server_handle.abort();
}
}