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;
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#[derive(Debug, Parser)]
38struct CliOpts {
39 #[arg(long, env = "FM_BIND_API", default_value = "0.0.0.0:8176")]
44 bind_api: SocketAddr,
45 #[arg(long, env = "FM_API_ADDRESS")]
52 api_address: SafeUrl,
53}
54
55#[derive(Clone)]
56struct AppState {
57 api_address: SafeUrl,
58 gateway_conn: RealGatewayConnection,
59}
60
61#[tokio::main]
62async fn main() -> anyhow::Result<()> {
63 TracingSetup::default().init()?;
64
65 let cli_opts = CliOpts::parse();
66
67 let connector_registry = ConnectorRegistry::build_from_client_defaults()
68 .with_env_var_overrides()?
69 .bind()
70 .await?;
71
72 if cli_opts.api_address.scheme() != "https" {
73 warn!(
74 api_address = %cli_opts.api_address,
75 "Api address is not an https URL, payers may be exposed to invoice tampering"
76 );
77 }
78
79 let state = AppState {
80 api_address: cli_opts.api_address.clone(),
81 gateway_conn: RealGatewayConnection {
82 api: GatewayApi::new(None, connector_registry),
83 },
84 };
85
86 let cors = CorsLayer::new()
87 .allow_origin(cors::Any)
88 .allow_methods(cors::Any)
89 .allow_headers(cors::Any);
90
91 let app = Router::new()
92 .route("/", get(health_check))
93 .route("/pay/{payload}", get(pay))
94 .route("/invoice/{payload}", get(invoice))
95 .layer(cors)
96 .with_state(state);
97
98 info!(
99 bind_api = %cli_opts.bind_api,
100 api_address = %cli_opts.api_address,
101 "recurringdv2 started"
102 );
103
104 let listener = TcpListener::bind(cli_opts.bind_api).await?;
105
106 axum::serve(listener, app).await?;
107
108 Ok(())
109}
110
111async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
112 format!("recurringdv2 is up and running at {}", state.api_address)
113}
114
115async fn pay(
116 State(state): State<AppState>,
117 Path(payload): Path<String>,
118) -> Json<LnurlResponse<PayResponse>> {
119 Json(LnurlResponse::Ok(PayResponse {
120 callback: state
121 .api_address
122 .join_path(&format!("invoice/{payload}"))
123 .to_string(),
124 max_sendable: MAX_SENDABLE_MSAT,
125 min_sendable: MIN_SENDABLE_MSAT,
126 tag: pay_request_tag(),
127 metadata: "[[\"text/plain\", \"Pay to Recurringd\"]]".to_string(),
128 }))
129}
130
131#[derive(Debug, Serialize, Deserialize)]
132struct GetInvoiceParams {
133 amount: u64,
134}
135
136async fn invoice(
137 Path(payload): Path<String>,
138 Query(params): Query<GetInvoiceParams>,
139 State(state): State<AppState>,
140) -> Json<LnurlResponse<InvoiceResponse>> {
141 let Ok(request) = decode_prefixed::<LnurlRequest>(FEDIMINT_PREFIX, &payload) else {
142 return Json(LnurlResponse::error("Failed to decode payload"));
143 };
144
145 if params.amount < MIN_SENDABLE_MSAT || params.amount > MAX_SENDABLE_MSAT {
146 return Json(LnurlResponse::error(format!(
147 "Amount must be between {} and {}",
148 MIN_SENDABLE_MSAT, MAX_SENDABLE_MSAT
149 )));
150 }
151
152 let (gateway, invoice) = match create_contract_and_fetch_invoice(
153 request.federation_id,
154 request.recipient_pk,
155 request.aggregate_pk,
156 request.gateways,
157 params.amount,
158 3600, &state.gateway_conn,
160 )
161 .await
162 {
163 Ok(result) => result,
164 Err(e) => {
165 return Json(LnurlResponse::error(e.to_string()));
166 }
167 };
168
169 info!(%params.amount, %gateway, "Created invoice");
170
171 Json(LnurlResponse::Ok(InvoiceResponse {
172 pr: invoice.clone(),
173 verify: Some(
174 gateway
175 .join_path(&format!("verify/{}", invoice.payment_hash()))
176 .to_string(),
177 ),
178 }))
179}
180
181#[allow(clippy::too_many_arguments)]
182async fn create_contract_and_fetch_invoice(
183 federation_id: FederationId,
184 recipient_pk: PublicKey,
185 aggregate_pk: AggregatePublicKey,
186 gateways: Vec<SafeUrl>,
187 amount: u64,
188 expiry_secs: u32,
189 gateway_conn: &RealGatewayConnection,
190) -> anyhow::Result<(SafeUrl, Bolt11Invoice)> {
191 let (ephemeral_tweak, ephemeral_pk) = tweak::generate(recipient_pk);
192
193 let scalar = Scalar::from_be_bytes(ephemeral_tweak).expect("Within curve order");
194
195 let claim_pk = recipient_pk
196 .mul_tweak(secp256k1::SECP256K1, &scalar)
197 .expect("Tweak is valid");
198
199 let encryption_seed = ephemeral_tweak
200 .consensus_hash::<sha256::Hash>()
201 .to_byte_array();
202
203 let preimage = encryption_seed
204 .consensus_hash::<sha256::Hash>()
205 .to_byte_array();
206
207 let (routing_info, gateway) = select_gateway(gateways, federation_id, gateway_conn).await?;
208
209 ensure!(
210 routing_info
211 .receive_fee
212 .is_within(&PaymentFee::RECEIVE_FEE_LIMIT),
213 "Payment fee exceeds limit"
214 );
215
216 let contract_amount = routing_info.receive_fee.subtract_from(amount);
220
221 let expiration = fee_encoded_expiration(routing_info.receive_fee.fee(amount).msats);
227
228 let contract = IncomingContract::new(
229 aggregate_pk,
230 encryption_seed,
231 preimage,
232 PaymentImage::Hash(preimage.consensus_hash()),
233 contract_amount,
234 expiration,
235 claim_pk,
236 routing_info.module_public_key,
237 ephemeral_pk,
238 );
239
240 let invoice = gateway_conn
241 .bolt11_invoice(
242 gateway.clone(),
243 federation_id,
244 contract.clone(),
245 Amount::from_msats(amount),
246 Bolt11InvoiceDescription::Direct("LNURL Payment".to_string()),
247 expiry_secs,
248 )
249 .await?;
250
251 ensure!(
252 invoice.payment_hash() == &preimage.consensus_hash(),
253 "Invalid invoice payment hash"
254 );
255
256 ensure!(
257 invoice.amount_milli_satoshis() == Some(amount),
258 "Invalid invoice amount"
259 );
260
261 Ok((gateway, invoice))
262}
263
264async fn select_gateway(
265 gateways: Vec<SafeUrl>,
266 federation_id: FederationId,
267 gateway_conn: &RealGatewayConnection,
268) -> anyhow::Result<(RoutingInfo, SafeUrl)> {
269 for gateway in gateways {
270 if let Ok(Some(routing_info)) = gateway_conn
271 .routing_info(gateway.clone(), &federation_id)
272 .await
273 {
274 return Ok((routing_info, gateway));
275 }
276 }
277
278 bail!("All gateways are offline or do not support this federation")
279}