Skip to main content

fedimint_recurringdv2/
main.rs

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