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_lnv2_common::contracts::{IncomingContract, PaymentImage};
22use fedimint_lnv2_common::gateway_api::{
23 GatewayConnection, PaymentFee, RealGatewayConnection, RoutingInfo,
24};
25use fedimint_lnv2_common::lnurl::LnurlRequest;
26use fedimint_lnv2_common::{
27 Bolt11InvoiceDescription, GatewayApi, MINIMUM_INCOMING_CONTRACT_AMOUNT, tweak,
28};
29use fedimint_logging::TracingSetup;
30use lightning_invoice::Bolt11Invoice;
31use lnurl::Tag;
32use lnurl::pay::PayResponse;
33use serde::{Deserialize, Serialize};
34use tokio::net::TcpListener;
35use tower_http::cors;
36use tower_http::cors::CorsLayer;
37use tpe::AggregatePublicKey;
38use tracing::{info, warn};
39
40const MAX_SENDABLE_MSAT: u64 = 100_000_000_000;
41const MIN_SENDABLE_MSAT: u64 = 100_000;
42
43#[derive(Debug, Parser)]
44struct CliOpts {
45 #[arg(long, env = "FM_BIND_API", default_value = "0.0.0.0:8176")]
50 bind_api: SocketAddr,
51}
52
53#[derive(Clone)]
54struct AppState {
55 gateway_conn: RealGatewayConnection,
56}
57
58#[tokio::main]
59async fn main() -> anyhow::Result<()> {
60 TracingSetup::default().init()?;
61
62 let cli_opts = CliOpts::parse();
63
64 let connector_registry = ConnectorRegistry::build_from_client_defaults()
65 .with_env_var_overrides()?
66 .bind()
67 .await?;
68
69 let state = AppState {
70 gateway_conn: RealGatewayConnection {
71 api: GatewayApi::new(None, connector_registry),
72 },
73 };
74
75 let cors = CorsLayer::new()
76 .allow_origin(cors::Any)
77 .allow_methods(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: Tag::PayRequest,
123 metadata: "LNv2 Payment".to_string(),
124 comment_allowed: None,
125 allows_nostr: None,
126 nostr_pubkey: None,
127 };
128
129 Ok(Json(response))
130}
131
132#[derive(Debug, Serialize, Deserialize)]
133struct GetInvoiceParams {
134 amount: u64,
135}
136
137#[derive(Debug, Serialize, Deserialize)]
138struct LnUrlPayInvoiceResponse {
139 pr: Bolt11Invoice,
140 verify: String,
141}
142
143async fn invoice(
144 Path(payload): Path<String>,
145 Query(params): Query<GetInvoiceParams>,
146 State(state): State<AppState>,
147) -> Result<Json<LnUrlPayInvoiceResponse>, LnurlError> {
148 let request: LnurlRequest = decode_prefixed(FEDIMINT_PREFIX, &payload)
149 .map_err(|_| LnurlError::bad_request(anyhow!("Failed to decode payload")))?;
150
151 if params.amount < MIN_SENDABLE_MSAT || params.amount > MAX_SENDABLE_MSAT {
152 return Err(LnurlError::bad_request(anyhow!(
153 "Amount must be between {} and {}",
154 MIN_SENDABLE_MSAT,
155 MAX_SENDABLE_MSAT
156 )));
157 }
158
159 let (gateway, invoice) = create_contract_and_fetch_invoice(
160 request.federation_id,
161 request.recipient_pk,
162 request.aggregate_pk,
163 request.gateways,
164 params.amount,
165 3600, &state.gateway_conn,
167 )
168 .await
169 .map_err(LnurlError::internal)?;
170
171 info!(%params.amount, %gateway, "Created invoice");
172
173 Ok(Json(LnUrlPayInvoiceResponse {
174 pr: invoice.clone(),
175 verify: format!("{}/verify/{}", gateway, invoice.payment_hash()),
176 }))
177}
178
179#[allow(clippy::too_many_arguments)]
180async fn create_contract_and_fetch_invoice(
181 federation_id: FederationId,
182 recipient_pk: PublicKey,
183 aggregate_pk: AggregatePublicKey,
184 gateways: Vec<SafeUrl>,
185 amount: u64,
186 expiry_secs: u32,
187 gateway_conn: &RealGatewayConnection,
188) -> anyhow::Result<(SafeUrl, Bolt11Invoice)> {
189 let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_pk);
190
191 let scalar = Scalar::from_be_bytes(ephemeral_tweak).expect("Within curve order");
192
193 let claim_pk = recipient_pk
194 .mul_tweak(secp256k1::SECP256K1, &scalar)
195 .expect("Tweak is valid");
196
197 let encryption_seed = ephemeral_tweak
198 .consensus_hash::<sha256::Hash>()
199 .to_byte_array();
200
201 let preimage = encryption_seed
202 .consensus_hash::<sha256::Hash>()
203 .to_byte_array();
204
205 let (routing_info, gateway) = select_gateway(gateways, federation_id, gateway_conn).await?;
206
207 ensure!(
208 routing_info.receive_fee.le(&PaymentFee::RECEIVE_FEE_LIMIT),
209 "Payment fee exceeds limit"
210 );
211
212 let contract_amount = routing_info.receive_fee.subtract_from(amount);
213
214 ensure!(
215 contract_amount >= MINIMUM_INCOMING_CONTRACT_AMOUNT,
216 "Amount too small"
217 );
218
219 let expiration = duration_since_epoch()
220 .as_secs()
221 .saturating_add(u64::from(expiry_secs));
222
223 let contract = IncomingContract::new(
224 aggregate_pk,
225 encryption_seed,
226 preimage,
227 PaymentImage::Hash(preimage.consensus_hash()),
228 contract_amount,
229 expiration,
230 claim_pk,
231 routing_info.module_public_key,
232 ephemeral_pk,
233 );
234
235 let invoice = gateway_conn
236 .bolt11_invoice(
237 gateway.clone(),
238 federation_id,
239 contract.clone(),
240 Amount::from_msats(amount),
241 Bolt11InvoiceDescription::Direct("LNURL Payment".to_string()),
242 expiry_secs,
243 )
244 .await?;
245
246 ensure!(
247 invoice.payment_hash() == &preimage.consensus_hash(),
248 "Invalid invoice payment hash"
249 );
250
251 ensure!(
252 invoice.amount_milli_satoshis() == Some(amount),
253 "Invalid invoice amount"
254 );
255
256 Ok((gateway, invoice))
257}
258
259async fn select_gateway(
260 gateways: Vec<SafeUrl>,
261 federation_id: FederationId,
262 gateway_conn: &RealGatewayConnection,
263) -> anyhow::Result<(RoutingInfo, SafeUrl)> {
264 for gateway in gateways {
265 if let Ok(Some(routing_info)) = gateway_conn
266 .routing_info(gateway.clone(), &federation_id)
267 .await
268 {
269 return Ok((routing_info, gateway));
270 }
271 }
272
273 bail!("All gateways are offline or do not support this federation")
274}
275
276struct LnurlError {
277 code: StatusCode,
278 reason: anyhow::Error,
279}
280
281impl LnurlError {
282 fn bad_request(reason: anyhow::Error) -> Self {
283 Self {
284 code: StatusCode::BAD_REQUEST,
285 reason,
286 }
287 }
288
289 fn internal(reason: anyhow::Error) -> Self {
290 Self {
291 code: StatusCode::INTERNAL_SERVER_ERROR,
292 reason,
293 }
294 }
295}
296
297impl IntoResponse for LnurlError {
298 fn into_response(self) -> Response<Body> {
299 warn!(reason = %self.reason, "Request failed");
300
301 let json = Json(serde_json::json!({
302 "status": "ERROR",
303 "reason": self.reason.to_string(),
304 }));
305
306 (self.code, json).into_response()
307 }
308}