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