Skip to main content

fedimint_gateway_ui/
lib.rs

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