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