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