use anyhow::{anyhow, bail, ensure};
use axum::body::Body;
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use bitcoin::hashes::sha256;
use bitcoin::secp256k1::{self, PublicKey};
use fedimint_core::config::FederationId;
use fedimint_core::encoding::Encodable;
use fedimint_core::secp256k1::Scalar;
use fedimint_core::time::duration_since_epoch;
use fedimint_core::util::SafeUrl;
use fedimint_core::{Amount, BitcoinHash};
use fedimint_lnv2_common::contracts::{IncomingContract, PaymentImage};
use fedimint_lnv2_common::gateway_api::{
GatewayConnection, PaymentFee, RealGatewayConnection, RoutingInfo,
};
use fedimint_lnv2_common::lnurl::{LnurlRequest, LnurlResponse};
use fedimint_lnv2_common::{Bolt11InvoiceDescription, tweak};
use lightning_invoice::Bolt11Invoice;
use lnurl::Tag;
use lnurl::pay::PayResponse;
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use tpe::AggregatePublicKey;
use crate::encrypt::{Encryptable, EncryptedData};
const MAX_SENDABLE_MSAT: u64 = 100_000_000_000;
const MIN_SENDABLE_MSAT: u64 = 100_000;
#[derive(Clone)]
struct Recurringd {
base_url: SafeUrl,
encryption_key: [u8; 32],
}
#[derive(Debug, Serialize, Deserialize)]
struct GetInvoiceParams {
amount: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct LNURLPayInvoice {
pr: String,
verify: String,
}
struct LnurlError {
code: StatusCode,
reason: anyhow::Error,
}
impl LnurlError {
fn bad_request(reason: anyhow::Error) -> Self {
Self {
code: StatusCode::BAD_REQUEST,
reason,
}
}
fn internal(reason: anyhow::Error) -> Self {
Self {
code: StatusCode::INTERNAL_SERVER_ERROR,
reason,
}
}
}
impl IntoResponse for LnurlError {
fn into_response(self) -> Response<Body> {
let json = Json(serde_json::json!({
"status": "ERROR",
"reason": self.reason.to_string(),
}));
(self.code, json).into_response()
}
}
pub fn router(base_url: SafeUrl, encryption_key: String) -> Router {
let state = Recurringd {
base_url: base_url.clone(),
encryption_key: encryption_key
.consensus_hash::<sha256::Hash>()
.to_byte_array(),
};
Router::new()
.route("/", get(health_check))
.route("/lnurl", post(lnv2_register))
.route("/pay/{payload}", get(lnv2_pay))
.route("/invoice/{payload}", get(lnv2_invoice))
.with_state(state)
}
async fn health_check() -> impl IntoResponse {
"RecurringdV2 is up and running!"
}
async fn lnv2_register(
State(state): State<Recurringd>,
Json(request): Json<LnurlRequest>,
) -> Result<Json<LnurlResponse>, LnurlError> {
let payload = request.encrypt(&state.encryption_key).encode_base32();
Ok(Json(LnurlResponse {
lnurl: format!("{}pay/{}", state.base_url, payload),
}))
}
async fn lnv2_pay(
Path(payload): Path<String>,
State(state): State<Recurringd>,
) -> Result<Json<PayResponse>, LnurlError> {
let response = PayResponse {
callback: format!("{}invoice/{payload}", state.base_url),
max_sendable: MAX_SENDABLE_MSAT,
min_sendable: MIN_SENDABLE_MSAT,
tag: Tag::PayRequest,
metadata: "LNv2 Payment".to_string(),
comment_allowed: None,
allows_nostr: None,
nostr_pubkey: None,
};
Ok(Json(response))
}
async fn lnv2_invoice(
Path(payload): Path<String>,
Query(params): Query<GetInvoiceParams>,
State(state): State<Recurringd>,
) -> Result<Json<LNURLPayInvoice>, LnurlError> {
let request: LnurlRequest = EncryptedData::decode_base32(&payload)
.ok_or(LnurlError::bad_request(anyhow!("Failed to decode payload")))?
.decrypt(&state.encryption_key)
.ok_or(LnurlError::bad_request(anyhow!(
"Failed to decrypt payload"
)))?;
if params.amount < MIN_SENDABLE_MSAT || params.amount > MAX_SENDABLE_MSAT {
return Err(LnurlError::bad_request(anyhow!(
"Amount must be between {} and {}",
MIN_SENDABLE_MSAT,
MAX_SENDABLE_MSAT
)));
}
let (gateway, invoice) = create_contract_and_fetch_invoice(
request.federation_id,
request.recipient_pk,
request.aggregate_pk,
request.gateways,
params.amount,
3600, )
.await
.map_err(LnurlError::internal)?;
Ok(Json(LNURLPayInvoice {
pr: invoice.to_string(),
verify: format!("{}/verify/{}", gateway, invoice.payment_hash()),
}))
}
#[allow(clippy::too_many_arguments)]
pub async fn create_contract_and_fetch_invoice(
federation_id: FederationId,
recipient_pk: PublicKey,
aggregate_pk: AggregatePublicKey,
gateways: Vec<SafeUrl>,
amount: u64,
expiry_secs: u32,
) -> anyhow::Result<(SafeUrl, Bolt11Invoice)> {
let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_pk);
let scalar = Scalar::from_be_bytes(ephemeral_tweak).unwrap();
let claim_pk = recipient_pk
.mul_tweak(secp256k1::SECP256K1, &scalar)
.expect("Tweak is valid");
let encryption_seed = ephemeral_tweak
.consensus_hash::<sha256::Hash>()
.to_byte_array();
let preimage = encryption_seed
.consensus_hash::<sha256::Hash>()
.to_byte_array();
let (routing_info, gateway) = select_gateway(gateways, federation_id).await?;
ensure!(
routing_info.receive_fee.le(&PaymentFee::RECEIVE_FEE_LIMIT),
"Payment fee exceeds limit"
);
let contract_amount = routing_info.receive_fee.subtract_from(amount);
ensure!(contract_amount >= Amount::from_sats(5), "Dust amount");
let expiration = duration_since_epoch()
.as_secs()
.saturating_add(u64::from(expiry_secs));
let contract = IncomingContract::new(
aggregate_pk,
encryption_seed,
preimage,
PaymentImage::Hash(preimage.consensus_hash()),
contract_amount,
expiration,
claim_pk,
routing_info.module_public_key,
ephemeral_pk,
);
let invoice = RealGatewayConnection
.bolt11_invoice(
gateway.clone(),
federation_id,
contract.clone(),
Amount::from_msats(amount),
Bolt11InvoiceDescription::Direct("LNURL Payment".to_string()),
expiry_secs,
)
.await?;
ensure!(
invoice.payment_hash() == &preimage.consensus_hash(),
"Invalid invoice payment hash"
);
ensure!(
invoice.amount_milli_satoshis() == Some(amount),
"Invalid invoice amount"
);
Ok((gateway, invoice))
}
async fn select_gateway(
gateways: Vec<SafeUrl>,
federation_id: FederationId,
) -> anyhow::Result<(RoutingInfo, SafeUrl)> {
for gateway in gateways {
if let Ok(Some(routing_info)) = RealGatewayConnection
.routing_info(gateway.clone(), &federation_id)
.await
{
return Ok((routing_info, gateway));
}
}
bail!("All gateways are offline or do not support this federation")
}