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 #[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, &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 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}