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
37const LNURL_METADATA: &str = "[[\"text/plain\", \"Pay to Recurringd\"]]";
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 #[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, &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 let contract_amount = routing_info.receive_fee.subtract_from(amount);
226
227 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}