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::response::IntoResponse;
6use axum::routing::get;
7use axum::{Json, Router};
8use bitcoin::hashes::sha256;
9use bitcoin::secp256k1::{self, PublicKey};
10use clap::Parser;
11use fedimint_connectors::ConnectorRegistry;
12use fedimint_core::base32::{FEDIMINT_PREFIX, decode_prefixed};
13use fedimint_core::config::FederationId;
14use fedimint_core::encoding::Encodable;
15use fedimint_core::secp256k1::Scalar;
16use fedimint_core::util::SafeUrl;
17use fedimint_core::{Amount, BitcoinHash};
18use fedimint_lnurl::{InvoiceResponse, LnurlResponse, PayResponse, pay_request_tag};
19use fedimint_lnv2_common::contracts::{IncomingContract, PaymentImage, fee_encoded_expiration};
20use fedimint_lnv2_common::gateway_api::{
21    GatewayConnection, PaymentFee, RealGatewayConnection, RoutingInfo,
22};
23use fedimint_lnv2_common::lnurl::LnurlRequest;
24use fedimint_lnv2_common::{Bolt11InvoiceDescription, GatewayApi, tweak};
25use fedimint_logging::TracingSetup;
26use lightning_invoice::Bolt11Invoice;
27use serde::{Deserialize, Serialize};
28use tokio::net::TcpListener;
29use tower_http::cors;
30use tower_http::cors::CorsLayer;
31use tpe::AggregatePublicKey;
32use tracing::{info, warn};
33
34const MAX_SENDABLE_MSAT: u64 = 100_000_000_000;
35const MIN_SENDABLE_MSAT: u64 = 100_000;
36
37#[derive(Debug, Parser)]
38struct CliOpts {
39    /// Address to bind the server to
40    ///
41    /// Should be `0.0.0.0:8176` most of the time, as api connectivity is public
42    /// and direct, and the port should be open in the firewall.
43    #[arg(long, env = "FM_BIND_API", default_value = "0.0.0.0:8176")]
44    bind_api: SocketAddr,
45    /// Public base URL under which this service is reachable, e.g.
46    /// `https://lnurl.example.com/`
47    ///
48    /// Used to construct the LNURL-pay callback URLs returned to payers, so it
49    /// must be the exact URL payers reach this service at. Should be an
50    /// `https` URL in production.
51    #[arg(long, env = "FM_API_ADDRESS")]
52    api_address: SafeUrl,
53}
54
55#[derive(Clone)]
56struct AppState {
57    api_address: SafeUrl,
58    gateway_conn: RealGatewayConnection,
59}
60
61#[tokio::main]
62async fn main() -> anyhow::Result<()> {
63    TracingSetup::default().init()?;
64
65    let cli_opts = CliOpts::parse();
66
67    let connector_registry = ConnectorRegistry::build_from_client_defaults()
68        .with_env_var_overrides()?
69        .bind()
70        .await?;
71
72    if cli_opts.api_address.scheme() != "https" {
73        warn!(
74            api_address = %cli_opts.api_address,
75            "Api address is not an https URL, payers may be exposed to invoice tampering"
76        );
77    }
78
79    let state = AppState {
80        api_address: cli_opts.api_address.clone(),
81        gateway_conn: RealGatewayConnection {
82            api: GatewayApi::new(None, connector_registry),
83        },
84    };
85
86    let cors = CorsLayer::new()
87        .allow_origin(cors::Any)
88        .allow_methods(cors::Any)
89        .allow_headers(cors::Any);
90
91    let app = Router::new()
92        .route("/", get(health_check))
93        .route("/pay/{payload}", get(pay))
94        .route("/invoice/{payload}", get(invoice))
95        .layer(cors)
96        .with_state(state);
97
98    info!(
99        bind_api = %cli_opts.bind_api,
100        api_address = %cli_opts.api_address,
101        "recurringdv2 started"
102    );
103
104    let listener = TcpListener::bind(cli_opts.bind_api).await?;
105
106    axum::serve(listener, app).await?;
107
108    Ok(())
109}
110
111async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
112    format!("recurringdv2 is up and running at {}", state.api_address)
113}
114
115async fn pay(
116    State(state): State<AppState>,
117    Path(payload): Path<String>,
118) -> Json<LnurlResponse<PayResponse>> {
119    Json(LnurlResponse::Ok(PayResponse {
120        callback: state
121            .api_address
122            .join_path(&format!("invoice/{payload}"))
123            .to_string(),
124        max_sendable: MAX_SENDABLE_MSAT,
125        min_sendable: MIN_SENDABLE_MSAT,
126        tag: pay_request_tag(),
127        metadata: "[[\"text/plain\", \"Pay to Recurringd\"]]".to_string(),
128    }))
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132struct GetInvoiceParams {
133    amount: u64,
134}
135
136async fn invoice(
137    Path(payload): Path<String>,
138    Query(params): Query<GetInvoiceParams>,
139    State(state): State<AppState>,
140) -> Json<LnurlResponse<InvoiceResponse>> {
141    let Ok(request) = decode_prefixed::<LnurlRequest>(FEDIMINT_PREFIX, &payload) else {
142        return Json(LnurlResponse::error("Failed to decode payload"));
143    };
144
145    if params.amount < MIN_SENDABLE_MSAT || params.amount > MAX_SENDABLE_MSAT {
146        return Json(LnurlResponse::error(format!(
147            "Amount must be between {} and {}",
148            MIN_SENDABLE_MSAT, MAX_SENDABLE_MSAT
149        )));
150    }
151
152    let (gateway, invoice) = match create_contract_and_fetch_invoice(
153        request.federation_id,
154        request.recipient_pk,
155        request.aggregate_pk,
156        request.gateways,
157        params.amount,
158        3600, // standard expiry time of one hour
159        &state.gateway_conn,
160    )
161    .await
162    {
163        Ok(result) => result,
164        Err(e) => {
165            return Json(LnurlResponse::error(e.to_string()));
166        }
167    };
168
169    info!(%params.amount, %gateway, "Created invoice");
170
171    Json(LnurlResponse::Ok(InvoiceResponse {
172        pr: invoice.clone(),
173        verify: Some(
174            gateway
175                .join_path(&format!("verify/{}", invoice.payment_hash()))
176                .to_string(),
177        ),
178    }))
179}
180
181#[allow(clippy::too_many_arguments)]
182async fn create_contract_and_fetch_invoice(
183    federation_id: FederationId,
184    recipient_pk: PublicKey,
185    aggregate_pk: AggregatePublicKey,
186    gateways: Vec<SafeUrl>,
187    amount: u64,
188    expiry_secs: u32,
189    gateway_conn: &RealGatewayConnection,
190) -> anyhow::Result<(SafeUrl, Bolt11Invoice)> {
191    let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_pk);
192
193    let scalar = Scalar::from_be_bytes(ephemeral_tweak).expect("Within curve order");
194
195    let claim_pk = recipient_pk
196        .mul_tweak(secp256k1::SECP256K1, &scalar)
197        .expect("Tweak is valid");
198
199    let encryption_seed = ephemeral_tweak
200        .consensus_hash::<sha256::Hash>()
201        .to_byte_array();
202
203    let preimage = encryption_seed
204        .consensus_hash::<sha256::Hash>()
205        .to_byte_array();
206
207    let (routing_info, gateway) = select_gateway(gateways, federation_id, gateway_conn).await?;
208
209    ensure!(
210        routing_info
211            .receive_fee
212            .is_within(&PaymentFee::RECEIVE_FEE_LIMIT),
213        "Payment fee exceeds limit"
214    );
215
216    // `amount` is at least `MIN_SENDABLE_MSAT` and the fee just cleared
217    // `RECEIVE_FEE_LIMIT`, so this cannot produce a contract below 49.5 sats - far
218    // above any federation's claim fee. The recipient prices its own claim.
219    let contract_amount = routing_info.receive_fee.subtract_from(amount);
220
221    // Encode the gateway fee in the contract expiration so the receiving client
222    // can recover it and report the invoice amount in its payment events. These
223    // contracts are discovered via the contract stream rather than awaited
224    // per-invoice, so a real expiration is not needed here; the bolt11 invoice
225    // still carries the real expiry of `expiry_secs`.
226    let expiration = fee_encoded_expiration(routing_info.receive_fee.fee(amount).msats);
227
228    let contract = IncomingContract::new(
229        aggregate_pk,
230        encryption_seed,
231        preimage,
232        PaymentImage::Hash(preimage.consensus_hash()),
233        contract_amount,
234        expiration,
235        claim_pk,
236        routing_info.module_public_key,
237        ephemeral_pk,
238    );
239
240    let invoice = gateway_conn
241        .bolt11_invoice(
242            gateway.clone(),
243            federation_id,
244            contract.clone(),
245            Amount::from_msats(amount),
246            Bolt11InvoiceDescription::Direct("LNURL Payment".to_string()),
247            expiry_secs,
248        )
249        .await?;
250
251    ensure!(
252        invoice.payment_hash() == &preimage.consensus_hash(),
253        "Invalid invoice payment hash"
254    );
255
256    ensure!(
257        invoice.amount_milli_satoshis() == Some(amount),
258        "Invalid invoice amount"
259    );
260
261    Ok((gateway, invoice))
262}
263
264async fn select_gateway(
265    gateways: Vec<SafeUrl>,
266    federation_id: FederationId,
267    gateway_conn: &RealGatewayConnection,
268) -> anyhow::Result<(RoutingInfo, SafeUrl)> {
269    for gateway in gateways {
270        if let Ok(Some(routing_info)) = gateway_conn
271            .routing_info(gateway.clone(), &federation_id)
272            .await
273        {
274            return Ok((routing_info, gateway));
275        }
276    }
277
278    bail!("All gateways are offline or do not support this federation")
279}