fedimint_recurringdv2/
main.rs

1use std::net::SocketAddr;
2
3use anyhow::{anyhow, bail, ensure};
4use axum::body::Body;
5use axum::extract::{Path, Query, State};
6use axum::http::{HeaderMap, StatusCode};
7use axum::response::{IntoResponse, Response};
8use axum::routing::get;
9use axum::{Json, Router};
10use bitcoin::hashes::sha256;
11use bitcoin::secp256k1::{self, PublicKey};
12use clap::Parser;
13use fedimint_connectors::ConnectorRegistry;
14use fedimint_core::base32::{FEDIMINT_PREFIX, decode_prefixed};
15use fedimint_core::config::FederationId;
16use fedimint_core::encoding::Encodable;
17use fedimint_core::secp256k1::Scalar;
18use fedimint_core::time::duration_since_epoch;
19use fedimint_core::util::SafeUrl;
20use fedimint_core::{Amount, BitcoinHash};
21use fedimint_lnv2_common::contracts::{IncomingContract, PaymentImage};
22use fedimint_lnv2_common::gateway_api::{
23    GatewayConnection, PaymentFee, RealGatewayConnection, RoutingInfo,
24};
25use fedimint_lnv2_common::lnurl::LnurlRequest;
26use fedimint_lnv2_common::{
27    Bolt11InvoiceDescription, GatewayApi, MINIMUM_INCOMING_CONTRACT_AMOUNT, tweak,
28};
29use fedimint_logging::TracingSetup;
30use lightning_invoice::Bolt11Invoice;
31use lnurl::Tag;
32use lnurl::pay::PayResponse;
33use serde::{Deserialize, Serialize};
34use tokio::net::TcpListener;
35use tower_http::cors;
36use tower_http::cors::CorsLayer;
37use tpe::AggregatePublicKey;
38use tracing::{info, warn};
39
40const MAX_SENDABLE_MSAT: u64 = 100_000_000_000;
41const MIN_SENDABLE_MSAT: u64 = 100_000;
42
43#[derive(Debug, Parser)]
44struct CliOpts {
45    /// Address to bind the server to
46    ///
47    /// Should be `0.0.0.0:8176` most of the time, as api connectivity is public
48    /// and direct, and the port should be open in the firewall.
49    #[arg(long, env = "FM_BIND_API", default_value = "0.0.0.0:8176")]
50    bind_api: SocketAddr,
51}
52
53#[derive(Clone)]
54struct AppState {
55    gateway_conn: RealGatewayConnection,
56}
57
58#[tokio::main]
59async fn main() -> anyhow::Result<()> {
60    TracingSetup::default().init()?;
61
62    let cli_opts = CliOpts::parse();
63
64    let connector_registry = ConnectorRegistry::build_from_client_defaults()
65        .with_env_var_overrides()?
66        .bind()
67        .await?;
68
69    let state = AppState {
70        gateway_conn: RealGatewayConnection {
71            api: GatewayApi::new(None, connector_registry),
72        },
73    };
74
75    let cors = CorsLayer::new()
76        .allow_origin(cors::Any)
77        .allow_methods(cors::Any)
78        .allow_headers(cors::Any);
79
80    let app = Router::new()
81        .route("/", get(health_check))
82        .route("/pay/{payload}", get(pay))
83        .route("/invoice/{payload}", get(invoice))
84        .layer(cors)
85        .with_state(state);
86
87    info!(bind_api = %cli_opts.bind_api, "recurringdv2 started");
88
89    let listener = TcpListener::bind(cli_opts.bind_api).await?;
90
91    axum::serve(listener, app).await?;
92
93    Ok(())
94}
95
96async fn health_check(headers: HeaderMap) -> impl IntoResponse {
97    format!("recurringdv2 is up and running at {}", base_url(&headers))
98}
99
100fn base_url(headers: &HeaderMap) -> String {
101    let host = headers
102        .get("x-forwarded-host")
103        .or_else(|| headers.get("host"))
104        .and_then(|h| h.to_str().ok())
105        .unwrap_or("localhost");
106
107    let scheme = headers
108        .get("x-forwarded-proto")
109        .and_then(|h| h.to_str().ok())
110        .unwrap_or("http");
111
112    format!("{scheme}://{host}/")
113}
114
115async fn pay(
116    headers: HeaderMap,
117    Path(payload): Path<String>,
118) -> Result<Json<PayResponse>, LnurlError> {
119    let response = PayResponse {
120        callback: format!("{}invoice/{payload}", base_url(&headers)),
121        max_sendable: MAX_SENDABLE_MSAT,
122        min_sendable: MIN_SENDABLE_MSAT,
123        tag: Tag::PayRequest,
124        metadata: "LNv2 Payment".to_string(),
125        comment_allowed: None,
126        allows_nostr: None,
127        nostr_pubkey: None,
128    };
129
130    Ok(Json(response))
131}
132
133#[derive(Debug, Serialize, Deserialize)]
134struct GetInvoiceParams {
135    amount: u64,
136}
137
138#[derive(Debug, Serialize, Deserialize)]
139struct LnUrlPayInvoiceResponse {
140    pr: Bolt11Invoice,
141    verify: String,
142}
143
144async fn invoice(
145    Path(payload): Path<String>,
146    Query(params): Query<GetInvoiceParams>,
147    State(state): State<AppState>,
148) -> Result<Json<LnUrlPayInvoiceResponse>, LnurlError> {
149    let request: LnurlRequest = decode_prefixed(FEDIMINT_PREFIX, &payload)
150        .map_err(|_| LnurlError::bad_request(anyhow!("Failed to decode payload")))?;
151
152    if params.amount < MIN_SENDABLE_MSAT || params.amount > MAX_SENDABLE_MSAT {
153        return Err(LnurlError::bad_request(anyhow!(
154            "Amount must be between {} and {}",
155            MIN_SENDABLE_MSAT,
156            MAX_SENDABLE_MSAT
157        )));
158    }
159
160    let (gateway, invoice) = create_contract_and_fetch_invoice(
161        request.federation_id,
162        request.recipient_pk,
163        request.aggregate_pk,
164        request.gateways,
165        params.amount,
166        3600, // standard expiry time of one hour
167        &state.gateway_conn,
168    )
169    .await
170    .map_err(LnurlError::internal)?;
171
172    info!(%params.amount, %gateway, "Created invoice");
173
174    Ok(Json(LnUrlPayInvoiceResponse {
175        pr: invoice.clone(),
176        verify: format!("{}/verify/{}", gateway, invoice.payment_hash()),
177    }))
178}
179
180#[allow(clippy::too_many_arguments)]
181async fn create_contract_and_fetch_invoice(
182    federation_id: FederationId,
183    recipient_pk: PublicKey,
184    aggregate_pk: AggregatePublicKey,
185    gateways: Vec<SafeUrl>,
186    amount: u64,
187    expiry_secs: u32,
188    gateway_conn: &RealGatewayConnection,
189) -> anyhow::Result<(SafeUrl, Bolt11Invoice)> {
190    let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_pk);
191
192    let scalar = Scalar::from_be_bytes(ephemeral_tweak).expect("Within curve order");
193
194    let claim_pk = recipient_pk
195        .mul_tweak(secp256k1::SECP256K1, &scalar)
196        .expect("Tweak is valid");
197
198    let encryption_seed = ephemeral_tweak
199        .consensus_hash::<sha256::Hash>()
200        .to_byte_array();
201
202    let preimage = encryption_seed
203        .consensus_hash::<sha256::Hash>()
204        .to_byte_array();
205
206    let (routing_info, gateway) = select_gateway(gateways, federation_id, gateway_conn).await?;
207
208    ensure!(
209        routing_info.receive_fee.le(&PaymentFee::RECEIVE_FEE_LIMIT),
210        "Payment fee exceeds limit"
211    );
212
213    let contract_amount = routing_info.receive_fee.subtract_from(amount);
214
215    ensure!(
216        contract_amount >= MINIMUM_INCOMING_CONTRACT_AMOUNT,
217        "Amount too small"
218    );
219
220    let expiration = duration_since_epoch()
221        .as_secs()
222        .saturating_add(u64::from(expiry_secs));
223
224    let contract = IncomingContract::new(
225        aggregate_pk,
226        encryption_seed,
227        preimage,
228        PaymentImage::Hash(preimage.consensus_hash()),
229        contract_amount,
230        expiration,
231        claim_pk,
232        routing_info.module_public_key,
233        ephemeral_pk,
234    );
235
236    let invoice = gateway_conn
237        .bolt11_invoice(
238            gateway.clone(),
239            federation_id,
240            contract.clone(),
241            Amount::from_msats(amount),
242            Bolt11InvoiceDescription::Direct("LNURL Payment".to_string()),
243            expiry_secs,
244        )
245        .await?;
246
247    ensure!(
248        invoice.payment_hash() == &preimage.consensus_hash(),
249        "Invalid invoice payment hash"
250    );
251
252    ensure!(
253        invoice.amount_milli_satoshis() == Some(amount),
254        "Invalid invoice amount"
255    );
256
257    Ok((gateway, invoice))
258}
259
260async fn select_gateway(
261    gateways: Vec<SafeUrl>,
262    federation_id: FederationId,
263    gateway_conn: &RealGatewayConnection,
264) -> anyhow::Result<(RoutingInfo, SafeUrl)> {
265    for gateway in gateways {
266        if let Ok(Some(routing_info)) = gateway_conn
267            .routing_info(gateway.clone(), &federation_id)
268            .await
269        {
270            return Ok((routing_info, gateway));
271        }
272    }
273
274    bail!("All gateways are offline or do not support this federation")
275}
276
277struct LnurlError {
278    code: StatusCode,
279    reason: anyhow::Error,
280}
281
282impl LnurlError {
283    fn bad_request(reason: anyhow::Error) -> Self {
284        Self {
285            code: StatusCode::BAD_REQUEST,
286            reason,
287        }
288    }
289
290    fn internal(reason: anyhow::Error) -> Self {
291        Self {
292            code: StatusCode::INTERNAL_SERVER_ERROR,
293            reason,
294        }
295    }
296}
297
298impl IntoResponse for LnurlError {
299    fn into_response(self) -> Response<Body> {
300        warn!(reason = %self.reason, "Request failed");
301
302        let json = Json(serde_json::json!({
303            "status": "ERROR",
304            "reason": self.reason.to_string(),
305        }));
306
307        (self.code, json).into_response()
308    }
309}