1mod bitcoin;
2mod connect_fed;
3mod federation;
4mod general;
5mod lightning;
6mod mnemonic;
7mod payment_summary;
8mod setup;
9
10use std::collections::BTreeMap;
11use std::fmt::Display;
12use std::sync::Arc;
13
14use ::bitcoin::{Address, Txid};
15use async_trait::async_trait;
16use axum::body::Body;
17use axum::extract::{Query, State};
18use axum::http::header;
19use axum::response::{Html, IntoResponse, Redirect, Response};
20use axum::routing::{get, post};
21use axum::{Form, Router, middleware};
22use axum_extra::extract::CookieJar;
23use axum_extra::extract::cookie::{Cookie, SameSite};
24use fedimint_core::bitcoin::Network;
25use fedimint_core::config::FederationId;
26use fedimint_core::invite_code::InviteCode;
27use fedimint_core::secp256k1::serde::Deserialize;
28use fedimint_core::task::TaskGroup;
29use fedimint_core::{PeerId, TieredCounts};
30use fedimint_gateway_common::{
31 ChainSource, CloseChannelsWithPeerRequest, CloseChannelsWithPeerResponse, ConnectFedPayload,
32 ConnectPeerRequest, CreateInvoiceForOperatorPayload, CreateOfferPayload, CreateOfferResponse,
33 DepositAddressPayload, FederationInfo, GatewayBalances, GatewayInfo, LeaveFedPayload,
34 LightningMode, ListTransactionsPayload, ListTransactionsResponse, MnemonicResponse,
35 OpenChannelRequest, PayInvoiceForOperatorPayload, PayOfferPayload, PayOfferResponse,
36 PaymentLogPayload, PaymentLogResponse, PaymentSummaryPayload, PaymentSummaryResponse,
37 ReceiveEcashPayload, ReceiveEcashResponse, SendOnchainRequest, SetFeesPayload,
38 SetMnemonicPayload, SetPaymentPolicyPayload, SpendEcashPayload, SpendEcashResponse,
39 WithdrawPayload, WithdrawPreviewPayload, WithdrawPreviewResponse, WithdrawResponse,
40};
41use fedimint_ln_common::contracts::Preimage;
42use fedimint_logging::LOG_GATEWAY_UI;
43use fedimint_ui_common::assets::WithStaticRoutesExt;
44use fedimint_ui_common::auth::{UserAuth, csrf_protection_middleware};
45use fedimint_ui_common::{
46 LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState, dashboard_layout,
47 login_form as render_login_form, single_card_layout,
48};
49use lightning_invoice::Bolt11Invoice;
50use maud::html;
51use tracing::debug;
52
53use crate::connect_fed::connect_federation_handler;
54use crate::federation::{
55 deposit_address_handler, leave_federation_handler, receive_ecash_handler, set_fees_handler,
56 set_payment_policy_handler, spend_ecash_handler, withdraw_confirm_handler,
57 withdraw_preview_handler,
58};
59use crate::lightning::{
60 channels_fragment_handler, close_channel_handler, connect_peer_handler,
61 create_bolt11_invoice_handler, create_receive_invoice_handler, detect_payment_type_handler,
62 generate_receive_address_handler, open_channel_handler, pay_bolt11_invoice_handler,
63 pay_unified_handler, payments_fragment_handler, send_onchain_handler, set_channel_fees_handler,
64 transactions_fragment_handler, wallet_fragment_handler,
65};
66use crate::mnemonic::{mnemonic_iframe_handler, mnemonic_reveal_handler};
67use crate::payment_summary::payment_log_fragment_handler;
68use crate::setup::{create_wallet_handler, recover_wallet_form, recover_wallet_handler};
69pub type DynGatewayApi<E> = Arc<dyn IAdminGateway<Error = E> + Send + Sync + 'static>;
70
71pub(crate) const OPEN_CHANNEL_ROUTE: &str = "/ui/channels/open";
72pub(crate) const CLOSE_CHANNEL_ROUTE: &str = "/ui/channels/close";
73pub(crate) const SET_CHANNEL_FEES_ROUTE: &str = "/ui/channels/fees";
74pub(crate) const CONNECT_PEER_ROUTE: &str = "/ui/peers/connect";
75pub(crate) const CHANNEL_FRAGMENT_ROUTE: &str = "/ui/channels/fragment";
76pub(crate) const LEAVE_FEDERATION_ROUTE: &str = "/ui/federations/{id}/leave";
77pub(crate) const CONNECT_FEDERATION_ROUTE: &str = "/ui/federations/join";
78pub(crate) const SET_FEES_ROUTE: &str = "/ui/federation/set-fees";
79pub(crate) const SET_PAYMENT_POLICY_ROUTE: &str = "/ui/federation/set-payment-policy";
80pub(crate) const SEND_ONCHAIN_ROUTE: &str = "/ui/wallet/send";
81pub(crate) const WALLET_FRAGMENT_ROUTE: &str = "/ui/wallet/fragment";
82pub(crate) const LN_ONCHAIN_ADDRESS_ROUTE: &str = "/ui/wallet/receive";
83pub(crate) const DEPOSIT_ADDRESS_ROUTE: &str = "/ui/federations/deposit-address";
84pub(crate) const PAYMENTS_FRAGMENT_ROUTE: &str = "/ui/payments/fragment";
85pub(crate) const CREATE_BOLT11_INVOICE_ROUTE: &str = "/ui/payments/receive/bolt11";
86pub(crate) const CREATE_RECEIVE_INVOICE_ROUTE: &str = "/ui/payments/receive";
87pub(crate) const PAY_BOLT11_INVOICE_ROUTE: &str = "/ui/payments/send/bolt11";
88pub(crate) const PAY_UNIFIED_ROUTE: &str = "/ui/payments/send";
89pub(crate) const DETECT_PAYMENT_TYPE_ROUTE: &str = "/ui/payments/detect";
90pub(crate) const TRANSACTIONS_FRAGMENT_ROUTE: &str = "/ui/transactions/fragment";
91pub(crate) const RECEIVE_ECASH_ROUTE: &str = "/ui/federations/receive";
92pub(crate) const STOP_GATEWAY_ROUTE: &str = "/ui/stop";
93pub(crate) const WITHDRAW_PREVIEW_ROUTE: &str = "/ui/federations/withdraw-preview";
94pub(crate) const WITHDRAW_CONFIRM_ROUTE: &str = "/ui/federations/withdraw-confirm";
95pub(crate) const SPEND_ECASH_ROUTE: &str = "/ui/federations/spend";
96pub(crate) const PAYMENT_LOG_ROUTE: &str = "/ui/payment-log";
97pub(crate) const CREATE_WALLET_ROUTE: &str = "/ui/wallet/create";
98pub(crate) const RECOVER_WALLET_ROUTE: &str = "/ui/wallet/recover";
99pub(crate) const MNEMONIC_IFRAME_ROUTE: &str = "/ui/mnemonic/iframe";
100pub(crate) const EXPORT_INVITE_CODES_ROUTE: &str = "/ui/export-invite-codes";
101
102#[derive(Default, Deserialize)]
103pub struct DashboardQuery {
104 pub success: Option<String>,
105 pub ui_error: Option<String>,
106 pub show_export_reminder: Option<bool>,
107}
108
109fn redirect_success(msg: String) -> impl IntoResponse {
110 let encoded: String = url::form_urlencoded::byte_serialize(msg.as_bytes()).collect();
111 Redirect::to(&format!("/?success={}", encoded))
112}
113
114pub(crate) fn redirect_success_with_export_reminder(msg: String) -> impl IntoResponse {
115 let encoded: String = url::form_urlencoded::byte_serialize(msg.as_bytes()).collect();
116 Redirect::to(&format!("/?success={}&show_export_reminder=true", encoded))
117}
118
119fn redirect_error(msg: String) -> impl IntoResponse {
120 let encoded: String = url::form_urlencoded::byte_serialize(msg.as_bytes()).collect();
121 Redirect::to(&format!("/?ui_error={}", encoded))
122}
123
124pub fn is_allowed_setup_route(path: &str) -> bool {
125 path == ROOT_ROUTE
126 || path == LOGIN_ROUTE
127 || path.starts_with("/assets/")
128 || path == CREATE_WALLET_ROUTE
129 || path == RECOVER_WALLET_ROUTE
130}
131
132#[async_trait]
133pub trait IAdminGateway {
134 type Error;
135
136 async fn handle_get_info(&self) -> Result<GatewayInfo, Self::Error>;
137
138 async fn handle_list_channels_msg(
139 &self,
140 ) -> Result<Vec<fedimint_gateway_common::ChannelInfo>, Self::Error>;
141
142 async fn handle_payment_summary_msg(
143 &self,
144 PaymentSummaryPayload {
145 start_millis,
146 end_millis,
147 }: PaymentSummaryPayload,
148 ) -> Result<PaymentSummaryResponse, Self::Error>;
149
150 async fn handle_leave_federation(
151 &self,
152 payload: LeaveFedPayload,
153 ) -> Result<FederationInfo, Self::Error>;
154
155 async fn handle_connect_federation(
156 &self,
157 payload: ConnectFedPayload,
158 ) -> Result<FederationInfo, Self::Error>;
159
160 async fn handle_set_fees_msg(&self, payload: SetFeesPayload) -> Result<(), Self::Error>;
161
162 async fn handle_set_payment_policy_msg(
163 &self,
164 payload: SetPaymentPolicyPayload,
165 ) -> Result<(), Self::Error>;
166
167 async fn handle_mnemonic_msg(&self) -> Result<MnemonicResponse, Self::Error>;
168
169 async fn handle_open_channel_msg(
170 &self,
171 payload: OpenChannelRequest,
172 ) -> Result<Txid, Self::Error>;
173
174 async fn handle_connect_peer_msg(&self, payload: ConnectPeerRequest)
175 -> Result<(), Self::Error>;
176
177 async fn handle_close_channels_with_peer_msg(
178 &self,
179 payload: CloseChannelsWithPeerRequest,
180 ) -> Result<CloseChannelsWithPeerResponse, Self::Error>;
181
182 async fn handle_set_channel_fees_msg(
183 &self,
184 payload: fedimint_gateway_common::SetChannelFeesRequest,
185 ) -> Result<(), Self::Error>;
186
187 async fn handle_get_balances_msg(&self) -> Result<GatewayBalances, Self::Error>;
188
189 async fn handle_send_onchain_msg(
190 &self,
191 payload: SendOnchainRequest,
192 ) -> Result<Txid, Self::Error>;
193
194 async fn handle_get_ln_onchain_address_msg(&self) -> Result<Address, Self::Error>;
195
196 async fn handle_deposit_address_msg(
197 &self,
198 payload: DepositAddressPayload,
199 ) -> Result<Address, Self::Error>;
200
201 async fn handle_receive_ecash_msg(
202 &self,
203 payload: ReceiveEcashPayload,
204 ) -> Result<ReceiveEcashResponse, Self::Error>;
205
206 async fn handle_create_invoice_for_operator_msg(
207 &self,
208 payload: CreateInvoiceForOperatorPayload,
209 ) -> Result<Bolt11Invoice, Self::Error>;
210
211 async fn handle_pay_invoice_for_operator_msg(
212 &self,
213 payload: PayInvoiceForOperatorPayload,
214 ) -> Result<Preimage, Self::Error>;
215
216 async fn handle_list_transactions_msg(
217 &self,
218 payload: ListTransactionsPayload,
219 ) -> Result<ListTransactionsResponse, Self::Error>;
220
221 async fn handle_spend_ecash_msg(
222 &self,
223 payload: SpendEcashPayload,
224 ) -> Result<SpendEcashResponse, Self::Error>;
225
226 async fn handle_shutdown_msg(&self, task_group: TaskGroup) -> Result<(), Self::Error>;
227
228 fn get_task_group(&self) -> TaskGroup;
229
230 async fn handle_withdraw_msg(
231 &self,
232 payload: WithdrawPayload,
233 ) -> Result<WithdrawResponse, Self::Error>;
234
235 async fn handle_withdraw_preview_msg(
236 &self,
237 payload: WithdrawPreviewPayload,
238 ) -> Result<WithdrawPreviewResponse, Self::Error>;
239
240 async fn handle_payment_log_msg(
241 &self,
242 payload: PaymentLogPayload,
243 ) -> Result<PaymentLogResponse, Self::Error>;
244
245 async fn handle_export_invite_codes(
246 &self,
247 ) -> BTreeMap<FederationId, BTreeMap<PeerId, (String, InviteCode)>>;
248
249 fn get_password_hash(&self) -> String;
250
251 fn gatewayd_version(&self) -> String;
252
253 async fn get_chain_source(&self) -> (ChainSource, Network);
254
255 fn lightning_mode(&self) -> LightningMode;
256
257 async fn is_configured(&self) -> bool;
258
259 async fn handle_set_mnemonic_msg(&self, payload: SetMnemonicPayload)
260 -> Result<(), Self::Error>;
261
262 async fn handle_create_offer_for_operator_msg(
263 &self,
264 payload: CreateOfferPayload,
265 ) -> Result<CreateOfferResponse, Self::Error>;
266
267 async fn handle_pay_offer_for_operator_msg(
268 &self,
269 payload: PayOfferPayload,
270 ) -> Result<PayOfferResponse, Self::Error>;
271
272 async fn handle_get_note_summary_msg(
273 &self,
274 federation_id: &FederationId,
275 ) -> Result<TieredCounts, Self::Error>;
276}
277
278async fn login_form_handler<E>(
279 State(_state): State<UiState<DynGatewayApi<E>>>,
280) -> impl IntoResponse {
281 Html(single_card_layout("Enter Password", render_login_form(None)).into_string())
282}
283
284async fn login_submit<E>(
286 State(state): State<UiState<DynGatewayApi<E>>>,
287 jar: CookieJar,
288 Form(input): Form<LoginInput>,
289) -> impl IntoResponse {
290 if let Ok(verify) = bcrypt::verify(&input.password, &state.api.get_password_hash())
291 && verify
292 {
293 let mut cookie = Cookie::new(state.auth_cookie_name.clone(), state.auth_cookie_value);
294 cookie.set_path(ROOT_ROUTE);
295
296 cookie.set_http_only(true);
297 cookie.set_same_site(Some(SameSite::Lax));
298
299 let jar = jar.add(cookie);
300 return (jar, [("HX-Redirect", "/")]).into_response();
301 }
302
303 Html(render_login_form(Some("The password is invalid")).into_string()).into_response()
304}
305
306async fn dashboard_view<E>(
307 State(state): State<UiState<DynGatewayApi<E>>>,
308 auth: UserAuth,
309 Query(msg): Query<DashboardQuery>,
310) -> impl IntoResponse
311where
312 E: std::fmt::Display,
313{
314 if !state.api.is_configured().await {
316 return setup::setup_view(State(state), auth, Query(msg))
317 .await
318 .into_response();
319 }
320
321 let gatewayd_version = state.api.gatewayd_version();
322 debug!(target: LOG_GATEWAY_UI, "Getting gateway info...");
323 let gateway_info = match state.api.handle_get_info().await {
324 Ok(info) => info,
325 Err(err) => {
326 let content = html! {
327 div class="alert alert-danger mt-4" {
328 strong { "Failed to fetch gateway info: " }
329 (err.to_string())
330 }
331 };
332 return Html(dashboard_layout(content, &gatewayd_version, None).into_string())
333 .into_response();
334 }
335 };
336
337 let content = html! {
338
339 (federation::scripts())
340
341 @if let Some(success) = msg.success {
342 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
343 span {
344 (success)
345 @if msg.show_export_reminder.unwrap_or(false) {
346 " "
347 a href=(EXPORT_INVITE_CODES_ROUTE) { "Export your invite codes for backup." }
348 }
349 }
350 a href=(ROOT_ROUTE)
351 class="ms-3 text-decoration-none text-dark fw-bold"
352 style="font-size: 1.5rem; line-height: 1; cursor: pointer;"
353 { "×" }
354 }
355 }
356 @if let Some(error) = msg.ui_error {
357 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
358 span { (error) }
359 a href=(ROOT_ROUTE)
360 class="ms-3 text-decoration-none text-dark fw-bold"
361 style="font-size: 1.5rem; line-height: 1; cursor: pointer;"
362 { "×" }
363 }
364 }
365
366 div class="row mt-4" {
367 div class="col-md-12 text-end" {
368 a href=(EXPORT_INVITE_CODES_ROUTE) class="btn btn-outline-primary me-2" {
369 "Export Invite Codes"
370 }
371 form action=(STOP_GATEWAY_ROUTE) method="post" style="display: inline;" {
372 button class="btn btn-outline-danger" type="submit"
373 onclick="return confirm('Are you sure you want to safely stop the gateway? The gateway will wait for outstanding payments and then shutdown.');"
374 {
375 "Safely Stop Gateway"
376 }
377 }
378 }
379 }
380
381 div class="row gy-4" {
382 div class="col-md-6" {
383 (general::render(&gateway_info))
384 }
385 div class="col-md-6" {
386 (payment_summary::render(&state.api, &gateway_info.federations).await)
387 }
388 }
389
390 div class="row gy-4 mt-2" {
391 div class="col-md-6" {
392 (bitcoin::render(&state.api).await)
393 }
394 div class="col-md-6" {
395 (mnemonic::render())
396 }
397 }
398
399 div class="row gy-4 mt-2" {
400 div class="col-md-12" {
401 (lightning::render(&gateway_info, &state.api).await)
402 }
403 }
404
405 div class="row gy-4 mt-2" {
406 div class="col-md-12" {
407 (connect_fed::render(&gateway_info.gateway_state))
408 }
409 }
410
411 @let invite_codes = state.api.handle_export_invite_codes().await;
412 @let empty_map = BTreeMap::new();
413
414 @for fed in &gateway_info.federations {
415 @let fed_codes = invite_codes.get(&fed.federation_id).unwrap_or(&empty_map);
416 @let note_summary = state.api.handle_get_note_summary_msg(&fed.federation_id).await;
417 (federation::render(fed, fed_codes, ¬e_summary))
418 }
419 };
420
421 Html(dashboard_layout(content, &gatewayd_version, None).into_string()).into_response()
422}
423
424async fn stop_gateway_handler<E>(
425 State(state): State<UiState<DynGatewayApi<E>>>,
426 _auth: UserAuth,
427) -> impl IntoResponse
428where
429 E: std::fmt::Display,
430{
431 match state
432 .api
433 .handle_shutdown_msg(state.api.get_task_group())
434 .await
435 {
436 Ok(_) => redirect_success("Gateway is safely shutting down...".to_string()).into_response(),
437 Err(err) => redirect_error(format!("Failed to stop gateway: {err}")).into_response(),
438 }
439}
440
441async fn export_invite_codes_handler<E>(
442 State(state): State<UiState<DynGatewayApi<E>>>,
443 _auth: UserAuth,
444) -> impl IntoResponse
445where
446 E: std::fmt::Display,
447{
448 let invite_codes: BTreeMap<FederationId, Vec<InviteCode>> = state
449 .api
450 .handle_export_invite_codes()
451 .await
452 .into_iter()
453 .map(|(fed_id, peers)| {
454 let codes = peers.into_values().map(|(_, code)| code).collect();
455 (fed_id, codes)
456 })
457 .collect();
458 let json = match serde_json::to_string_pretty(&invite_codes) {
459 Ok(json) => json,
460 Err(err) => {
461 return Response::builder()
462 .status(500)
463 .body(Body::from(format!(
464 "Failed to serialize invite codes: {err}"
465 )))
466 .expect("Failed to build error response");
467 }
468 };
469 let filename = "gateway-invite-codes.json";
470
471 Response::builder()
472 .header(header::CONTENT_TYPE, "application/json")
473 .header(
474 header::CONTENT_DISPOSITION,
475 format!("attachment; filename=\"{filename}\""),
476 )
477 .body(Body::from(json))
478 .expect("Failed to build response")
479}
480
481pub fn router<E: Display + Send + Sync + std::fmt::Debug + 'static>(
482 api: DynGatewayApi<E>,
483) -> Router {
484 let app = Router::new()
485 .route(ROOT_ROUTE, get(dashboard_view))
486 .route(LOGIN_ROUTE, get(login_form_handler).post(login_submit))
487 .route(OPEN_CHANNEL_ROUTE, post(open_channel_handler))
488 .route(CONNECT_PEER_ROUTE, post(connect_peer_handler))
489 .route(CLOSE_CHANNEL_ROUTE, post(close_channel_handler))
490 .route(SET_CHANNEL_FEES_ROUTE, post(set_channel_fees_handler))
491 .route(CHANNEL_FRAGMENT_ROUTE, get(channels_fragment_handler))
492 .route(WALLET_FRAGMENT_ROUTE, get(wallet_fragment_handler))
493 .route(LEAVE_FEDERATION_ROUTE, post(leave_federation_handler))
494 .route(CONNECT_FEDERATION_ROUTE, post(connect_federation_handler))
495 .route(SET_FEES_ROUTE, post(set_fees_handler))
496 .route(SET_PAYMENT_POLICY_ROUTE, post(set_payment_policy_handler))
497 .route(SEND_ONCHAIN_ROUTE, post(send_onchain_handler))
498 .route(
499 LN_ONCHAIN_ADDRESS_ROUTE,
500 get(generate_receive_address_handler),
501 )
502 .route(DEPOSIT_ADDRESS_ROUTE, post(deposit_address_handler))
503 .route(SPEND_ECASH_ROUTE, post(spend_ecash_handler))
504 .route(RECEIVE_ECASH_ROUTE, post(receive_ecash_handler))
505 .route(PAYMENTS_FRAGMENT_ROUTE, get(payments_fragment_handler))
506 .route(
507 CREATE_BOLT11_INVOICE_ROUTE,
508 post(create_bolt11_invoice_handler),
509 )
510 .route(
511 CREATE_RECEIVE_INVOICE_ROUTE,
512 post(create_receive_invoice_handler),
513 )
514 .route(PAY_BOLT11_INVOICE_ROUTE, post(pay_bolt11_invoice_handler))
515 .route(PAY_UNIFIED_ROUTE, post(pay_unified_handler))
516 .route(DETECT_PAYMENT_TYPE_ROUTE, post(detect_payment_type_handler))
517 .route(
518 TRANSACTIONS_FRAGMENT_ROUTE,
519 get(transactions_fragment_handler),
520 )
521 .route(STOP_GATEWAY_ROUTE, post(stop_gateway_handler))
522 .route(EXPORT_INVITE_CODES_ROUTE, get(export_invite_codes_handler))
523 .route(WITHDRAW_PREVIEW_ROUTE, post(withdraw_preview_handler))
524 .route(WITHDRAW_CONFIRM_ROUTE, post(withdraw_confirm_handler))
525 .route(PAYMENT_LOG_ROUTE, get(payment_log_fragment_handler))
526 .route(CREATE_WALLET_ROUTE, post(create_wallet_handler))
527 .route(
528 RECOVER_WALLET_ROUTE,
529 get(recover_wallet_form).post(recover_wallet_handler),
530 )
531 .route(
532 MNEMONIC_IFRAME_ROUTE,
533 get(mnemonic_iframe_handler).post(mnemonic_reveal_handler),
534 )
535 .with_static_routes()
536 .layer(middleware::from_fn(csrf_protection_middleware));
537
538 app.with_state(UiState::new(api, true))
539}