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