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