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