1use std::collections::BTreeSet;
2
3use axum::Router;
4use axum::extract::State;
5use axum::response::{Html, IntoResponse, Redirect};
6use axum::routing::{get, post};
7use axum_extra::extract::Form;
8use axum_extra::extract::cookie::CookieJar;
9use fedimint_core::core::ModuleKind;
10use fedimint_core::module::ApiAuth;
11use fedimint_server_core::setup_ui::DynSetupApi;
12use fedimint_ui_common::assets::WithStaticRoutesExt;
13use fedimint_ui_common::auth::UserAuth;
14use fedimint_ui_common::{
15 CONNECTIVITY_CHECK_ROUTE, LOGIN_ROUTE, LoginInput, ROOT_ROUTE, UiState,
16 connectivity_check_handler, connectivity_widget, login_form_response,
17};
18use maud::{DOCTYPE, Markup, PreEscaped, html};
19use qrcode::QrCode;
20use serde::Deserialize;
21
22use crate::{common_head, login_submit_response};
23
24pub const FEDERATION_SETUP_ROUTE: &str = "/federation_setup";
26pub const ADD_SETUP_CODE_ROUTE: &str = "/add_setup_code";
27pub const RESET_SETUP_CODES_ROUTE: &str = "/reset_setup_codes";
28pub const START_DKG_ROUTE: &str = "/start_dkg";
29
30#[derive(Debug, Deserialize)]
31pub(crate) struct SetupInput {
32 pub password: String,
33 pub name: String,
34 #[serde(default)]
35 pub is_lead: bool,
36 pub federation_name: String,
37 #[serde(default)]
38 pub federation_size: String,
39 #[serde(default)] pub enable_base_fees: bool,
41 #[serde(default)] pub enabled_modules: Vec<String>,
43}
44
45#[derive(Debug, Deserialize)]
46pub(crate) struct PeerInfoInput {
47 pub peer_info: String,
48}
49
50pub fn setup_layout(title: &str, content: Markup) -> Markup {
51 html! {
52 (DOCTYPE)
53 html {
54 head {
55 (common_head(title))
56 }
57 body {
58 div class="container" {
59 div class="row justify-content-center" {
60 div class="col-md-8 col-lg-5 narrow-container" {
61 header class="text-center" {
62 h1 class="header-title" { "Fedimint Guardian UI" }
63 }
64
65 div class="card" {
66 div class="card-body" {
67 (content)
68 }
69 }
70 }
71 }
72 }
73 (connectivity_widget())
74 script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" {}
75 script src="/assets/html5-qrcode.min.js" {}
76 }
77 }
78 }
79}
80
81async fn setup_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
83 if state.api.setup_code().await.is_some() {
84 return Redirect::to(FEDERATION_SETUP_ROUTE).into_response();
85 }
86
87 let available_modules = state.api.available_modules();
88 let default_modules = state.api.default_modules();
89
90 let content = html! {
91 form method="post" action=(ROOT_ROUTE)
92 hx-post=(ROOT_ROUTE) hx-target="#setup-error" hx-swap="innerHTML" {
93 style {
94 r#"
95 .toggle-content {
96 display: none;
97 }
98
99 .toggle-control:checked ~ .toggle-content {
100 display: block;
101 }
102
103 #base-fees-warning {
104 display: block;
105 }
106
107 .form-check:has(#enable_base_fees:checked) + #base-fees-warning {
108 display: none;
109 }
110
111 .accordion-button {
112 background-color: #f8f9fa;
113 }
114
115 .accordion-button:not(.collapsed) {
116 background-color: #f8f9fa;
117 box-shadow: none;
118 }
119
120 .accordion-button:focus {
121 box-shadow: none;
122 }
123
124 #modules-warning {
125 display: none;
126 }
127
128 #modules-list:has(.form-check-input:not(:checked)) ~ #modules-warning {
129 display: block;
130 }
131 "#
132 }
133
134 div class="form-group mb-4" {
135 input type="text" class="form-control" id="name" name="name" placeholder="Your Guardian Name" required;
136 }
137
138 div class="form-group mb-4" {
139 input type="password" class="form-control" id="password" name="password" placeholder="Your Password" required;
140 }
141
142 div class="alert alert-warning mb-3" style="font-size: 0.875rem;" {
143 "Exactly one guardian must set the global config."
144 }
145
146 div class="form-group mb-4" {
147 input type="checkbox" class="form-check-input toggle-control" id="is_lead" name="is_lead" value="true";
148
149 label class="form-check-label ms-2" for="is_lead" {
150 "Set the global config"
151 }
152
153 div class="toggle-content mt-3" {
154 input type="text" class="form-control" id="federation_name" name="federation_name" placeholder="Federation Name";
155
156 div class="form-group mt-3" {
157 label class="form-label" for="federation_size" {
158 "Total number of guardians (including you)"
159 }
160 input type="number" class="form-control" id="federation_size"
161 name="federation_size" min="1" max="19";
162 small class="form-text text-muted" {
163 "At least 4, or 1. Recommended: 4, 7, 10, 13."
164 }
165 }
166
167 div class="form-check mt-3" {
168 input type="checkbox" class="form-check-input" id="enable_base_fees" name="enable_base_fees" checked value="true";
169
170 label class="form-check-label" for="enable_base_fees" {
171 "Enable base fees for this federation"
172 }
173 }
174
175 div id="base-fees-warning" class="alert alert-warning mt-2" style="font-size: 0.875rem;" {
176 strong { "Warning: " }
177 "Base fees discourage spam and wasting storage space. The typical fee is only 1-3 sats per transaction, regardless of the value transferred. We recommend enabling the base fee and it cannot be changed later."
178 }
179
180 div class="accordion mt-3" id="modulesAccordion" {
181 div class="accordion-item" {
182 h2 class="accordion-header" {
183 button class="accordion-button collapsed" type="button"
184 data-bs-toggle="collapse" data-bs-target="#modulesConfig"
185 aria-expanded="false" aria-controls="modulesConfig" {
186 "Advanced: Configure Enabled Modules"
187 }
188 }
189 div id="modulesConfig" class="accordion-collapse collapse" data-bs-parent="#modulesAccordion" {
190 div class="accordion-body" {
191 div id="modules-list" {
192 @for kind in &available_modules {
193 div class="form-check" {
194 input type="checkbox" class="form-check-input"
195 id=(format!("module_{}", kind.as_str()))
196 name="enabled_modules"
197 value=(kind.as_str())
198 checked[default_modules.contains(kind)];
199
200 label class="form-check-label" for=(format!("module_{}", kind.as_str())) {
201 (kind.as_str())
202 @if !default_modules.contains(kind) {
203 span class="badge bg-warning text-dark ms-2" { "experimental" }
204 }
205 }
206 }
207 }
208 }
209
210 div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
211 "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
212 }
213 }
214 }
215 }
216 }
217 }
218 }
219
220 div id="setup-error" {}
221
222 div class="button-container" {
223 button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
224 }
225 }
226 };
227
228 Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
229}
230
231async fn setup_submit(
233 State(state): State<UiState<DynSetupApi>>,
234 Form(input): Form<SetupInput>,
235) -> impl IntoResponse {
236 let federation_name = if input.is_lead {
238 Some(input.federation_name)
239 } else {
240 None
241 };
242
243 let disable_base_fees = if input.is_lead {
244 Some(!input.enable_base_fees)
245 } else {
246 None
247 };
248
249 let enabled_modules = if input.is_lead {
250 let enabled: BTreeSet<ModuleKind> = input
251 .enabled_modules
252 .into_iter()
253 .map(|s| ModuleKind::clone_from_str(&s))
254 .collect();
255
256 Some(enabled)
257 } else {
258 None
259 };
260
261 let federation_size = if input.is_lead {
262 let s = input.federation_size.trim();
263 if s.is_empty() {
264 None
265 } else {
266 match s.parse::<u32>() {
267 Ok(size) => Some(size),
268 Err(_) => {
269 return Html(
270 html! {
271 div class="alert alert-danger" { "Invalid federation size" }
272 }
273 .into_string(),
274 )
275 .into_response();
276 }
277 }
278 }
279 } else {
280 None
281 };
282
283 match state
284 .api
285 .set_local_parameters(
286 ApiAuth::new(input.password),
287 input.name,
288 federation_name,
289 disable_base_fees,
290 enabled_modules,
291 federation_size,
292 )
293 .await
294 {
295 Ok(_) => ([("HX-Redirect", LOGIN_ROUTE)], Html(String::new())).into_response(),
296 Err(e) => Html(
297 html! {
298 div class="alert alert-danger" { (e.to_string()) }
299 }
300 .into_string(),
301 )
302 .into_response(),
303 }
304}
305
306async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
308 if state.api.setup_code().await.is_none() {
309 return Redirect::to(ROOT_ROUTE).into_response();
310 }
311
312 login_form_response("Fedimint Guardian Login").into_response()
313}
314
315async fn login_submit(
317 State(state): State<UiState<DynSetupApi>>,
318 jar: CookieJar,
319 Form(input): Form<LoginInput>,
320) -> impl IntoResponse {
321 let auth = match state.api.auth().await {
322 Some(auth) => auth,
323 None => return Redirect::to(ROOT_ROUTE).into_response(),
324 };
325
326 login_submit_response(
327 auth,
328 state.auth_cookie_name,
329 state.auth_cookie_value,
330 jar,
331 input,
332 )
333 .into_response()
334}
335
336async fn federation_setup(
338 State(state): State<UiState<DynSetupApi>>,
339 _auth: UserAuth,
340) -> impl IntoResponse {
341 let our_connection_info = state
342 .api
343 .setup_code()
344 .await
345 .expect("Successful authentication ensures that the local parameters have been set");
346
347 let connected_peers = state.api.connected_peers().await;
348 let guardian_name = state.api.guardian_name().await;
349 let federation_size = state.api.federation_size().await;
350 let cfg_federation_name = state.api.cfg_federation_name().await;
351 let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
352 let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
353 let total_guardians = connected_peers.len() + 1;
354 let can_start_dkg = federation_size
355 .map(|expected| total_guardians == expected as usize)
356 .unwrap_or(false);
357
358 let content = html! {
359 @if let Some(ref name) = guardian_name {
360 section class="mb-4" {
361 h4 { "Your name" }
362 p { (name) }
363 }
364 }
365
366 section class="mb-4" {
367 h4 { "Federation settings" }
368 @if cfg_federation_name.is_some() || federation_size.is_some() || cfg_base_fees_disabled.is_some() || cfg_enabled_modules.is_some() {
369 ul class="list-group list-group-flush" {
370 @if let Some(ref name) = cfg_federation_name {
371 li class="list-group-item" {
372 strong { "Federation name: " }
373 (name)
374 }
375 }
376 @if let Some(size) = federation_size {
377 li class="list-group-item" {
378 strong { "Federation size: " }
379 (size)
380 }
381 }
382 @if let Some(disabled) = cfg_base_fees_disabled {
383 li class="list-group-item" {
384 strong { "Base fees: " }
385 @if disabled { "disabled" } @else { "enabled" }
386 }
387 }
388 @if let Some(ref modules) = cfg_enabled_modules {
389 li class="list-group-item" {
390 strong { "Enabled modules: " }
391 (modules.iter().map(|m| m.as_str().to_owned()).collect::<Vec<_>>().join(", "))
392 }
393 }
394 }
395 } @else {
396 p class="text-muted" { "Leader's setup code not provided yet." }
397 }
398 }
399
400 hr class="my-4" {}
401
402 section class="mb-4" {
403 h4 { "Your setup code" }
404
405 p { "Share it with other guardians." }
406
407 @let qr_svg = QrCode::new(&our_connection_info)
408 .expect("Failed to generate QR code")
409 .render::<qrcode::render::svg::Color>()
410 .build();
411
412 div class="text-center mb-3" {
413 div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
414 div style="width: 100%; height: auto; overflow: hidden;" {
415 (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
416 qr_svg.replace("width=", "data-width=")
417 .replace("height=", "data-height=")
418 .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
419 }
420 }
421 }
422
423 div class="alert alert-info mb-3" {
424 (our_connection_info)
425 }
426
427 div class="text-center" {
428 button type="button" class="btn btn-outline-primary setup-btn"
429 onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
430 "Copy to Clipboard"
431 }
432 }
433 }
434
435 hr class="my-4" {}
436
437 section class="mb-4" {
438 h4 { "Other guardians" }
439
440 @if let Some(expected) = federation_size {
441 p { (format!("{total_guardians} of {expected} guardians connected.")) }
442 } @else {
443 p { "Add setup code of every other guardian." }
444 }
445
446 ul class="list-group mb-4" {
447 @for peer in connected_peers {
448 li class="list-group-item" { (peer) }
449 }
450 }
451
452 form method="post" action=(ADD_SETUP_CODE_ROUTE) {
453 div class="mb-3" {
454 div class="input-group" {
455 input type="text" class="form-control" id="peer_info" name="peer_info"
456 placeholder="Paste setup code" required;
457 button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
458 (PreEscaped(r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M15 12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.172a3 3 0 0 0 2.12-.879l.83-.828A1 1 0 0 1 6.827 3h2.344a1 1 0 0 1 .707.293l.828.828A3 3 0 0 0 12.828 5H14a1 1 0 0 1 1 1zM2 4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-1.172a2 2 0 0 1-1.414-.586l-.828-.828A2 2 0 0 0 9.172 2H6.828a2 2 0 0 0-1.414.586l-.828.828A2 2 0 0 1 3.172 4z"/><path d="M8 11a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5m0 1a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7M3 6.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/></svg>"#))
459 }
460 }
461 }
462
463 div class="row mt-3" {
464 div class="col-6" {
465 button type="button" class="btn btn-warning w-100" onclick="if(confirm('Are you sure you want to reset all guardians?')){document.getElementById('reset-form').submit();}" {
466 "Reset Guardians"
467 }
468 }
469
470 div class="col-6" {
471 button type="submit" class="btn btn-primary w-100"
472 disabled[can_start_dkg] {
473 @if can_start_dkg { "List complete" } @else { "Add Guardian" }
474 }
475 }
476 }
477 }
478
479 form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
480 }
481
482 hr class="my-4" {}
483
484 section class="mb-4" {
485 div class="alert alert-warning mb-4" {
486 "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
487 }
488
489 div class="text-center" {
490 form method="post" action=(START_DKG_ROUTE) {
491 button type="submit" class="btn btn-warning setup-btn"
492 disabled[!can_start_dkg] {
493 "🚀 Confirm"
494 }
495 }
496 @if !can_start_dkg {
497 @if let Some(expected) = federation_size {
498 p class="text-muted mt-2" style="font-size: 0.875rem;" {
499 (format!("Need to collect {} more setup code(s).", expected as usize - total_guardians))
500 }
501 } @else {
502 p class="text-muted mt-2" style="font-size: 0.875rem;" {
503 "Need to collect the setup codes from all other guardians."
504 }
505 }
506 }
507 }
508 }
509
510 div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
512 div class="modal-dialog modal-dialog-centered" {
513 div class="modal-content" {
514 div class="modal-header" {
515 h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
516 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
517 }
518 div class="modal-body" {
519 div id="qr-reader" style="width: 100%;" {}
520 div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
521 }
522 div class="modal-footer" {
523 button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
524 }
525 }
526 }
527 }
528
529 script {
531 (PreEscaped(r#"
532 var html5QrCode = null;
533 var qrScannerModal = null;
534
535 function startQrScanner() {
536 // Check for Flutter override hook
537 if (typeof window.fedimintQrScannerOverride === 'function') {
538 window.fedimintQrScannerOverride(function(result) {
539 if (result) {
540 document.getElementById('peer_info').value = result;
541 }
542 });
543 return;
544 }
545
546 var modalEl = document.getElementById('qrScannerModal');
547 qrScannerModal = new bootstrap.Modal(modalEl);
548
549 // Reset error message
550 var errorEl = document.getElementById('qr-reader-error');
551 errorEl.classList.add('d-none');
552 errorEl.textContent = '';
553
554 qrScannerModal.show();
555
556 // Wait for modal to be shown before starting camera
557 modalEl.addEventListener('shown.bs.modal', function onShown() {
558 modalEl.removeEventListener('shown.bs.modal', onShown);
559 initializeScanner();
560 });
561
562 // Clean up when modal is hidden
563 modalEl.addEventListener('hidden.bs.modal', function onHidden() {
564 modalEl.removeEventListener('hidden.bs.modal', onHidden);
565 stopQrScanner();
566 });
567 }
568
569 function initializeScanner() {
570 html5QrCode = new Html5Qrcode("qr-reader");
571
572 var config = {
573 fps: 10,
574 qrbox: { width: 250, height: 250 },
575 aspectRatio: 1.0
576 };
577
578 html5QrCode.start(
579 { facingMode: "environment" },
580 config,
581 function(decodedText, decodedResult) {
582 // Success - populate input and close modal
583 document.getElementById('peer_info').value = decodedText;
584 qrScannerModal.hide();
585 },
586 function(errorMessage) {
587 // Ignore scan errors (happens constantly while searching)
588 }
589 ).catch(function(err) {
590 var errorEl = document.getElementById('qr-reader-error');
591 errorEl.textContent = 'Unable to access camera: ' + err;
592 errorEl.classList.remove('d-none');
593 });
594 }
595
596 function stopQrScanner() {
597 if (html5QrCode && html5QrCode.isScanning) {
598 html5QrCode.stop().catch(function(err) {
599 console.error('Error stopping scanner:', err);
600 });
601 }
602 }
603 "#))
604 }
605 };
606
607 Html(setup_layout("Federation Setup", content).into_string()).into_response()
608}
609
610async fn post_add_setup_code(
612 State(state): State<UiState<DynSetupApi>>,
613 _auth: UserAuth,
614 Form(input): Form<PeerInfoInput>,
615) -> impl IntoResponse {
616 match state.api.add_peer_setup_code(input.peer_info).await {
617 Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
618 Err(e) => {
619 let content = html! {
620 div class="alert alert-danger" { (e.to_string()) }
621 div class="button-container" {
622 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
623 }
624 };
625
626 Html(setup_layout("Error", content).into_string()).into_response()
627 }
628 }
629}
630
631async fn post_start_dkg(
633 State(state): State<UiState<DynSetupApi>>,
634 _auth: UserAuth,
635) -> impl IntoResponse {
636 let our_connection_info = state.api.setup_code().await;
637
638 match state.api.start_dkg().await {
639 Ok(()) => {
640 let content = html! {
642 div class="alert alert-success my-4" {
643 "Setting up Federation..."
644 }
645
646 p class="text-center" {
647 "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
648 }
649
650 @if let Some(ref info) = our_connection_info {
651 hr class="my-4" {}
652 section class="mb-4" {
653 h4 { "Your setup code" }
654 p { "Share with guardians who still need it." }
655 div class="alert alert-info mb-3" {
656 (info)
657 }
658 div class="text-center" {
659 button type="button" class="btn btn-outline-primary setup-btn"
660 onclick=(format!("navigator.clipboard.writeText('{info}')")) {
661 "Copy to Clipboard"
662 }
663 }
664 }
665 }
666
667 div
669 hx-get=(ROOT_ROUTE)
670 hx-trigger="every 2s"
671 hx-swap="none"
672 hx-on--after-request={
673 "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
674 }
675 style="display: none;"
676 {}
677
678 div class="text-center mt-4" {
679 div class="spinner-border text-primary" role="status" {
680 span class="visually-hidden" { "Loading..." }
681 }
682 p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
683 }
684 };
685
686 Html(setup_layout("DKG Started", content).into_string()).into_response()
687 }
688 Err(e) => {
689 let content = html! {
690 div class="alert alert-danger" { (e.to_string()) }
691 div class="button-container" {
692 a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
693 }
694 };
695
696 Html(setup_layout("Error", content).into_string()).into_response()
697 }
698 }
699}
700
701async fn post_reset_setup_codes(
703 State(state): State<UiState<DynSetupApi>>,
704 _auth: UserAuth,
705) -> impl IntoResponse {
706 state.api.reset_setup_codes().await;
707
708 Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
709}
710
711pub fn router(api: DynSetupApi) -> Router {
712 Router::new()
713 .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
714 .route(LOGIN_ROUTE, get(login_form).post(login_submit))
715 .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
716 .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
717 .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
718 .route(START_DKG_ROUTE, post(post_start_dkg))
719 .route(
720 CONNECTIVITY_CHECK_ROUTE,
721 get(connectivity_check_handler::<DynSetupApi>),
722 )
723 .with_static_routes()
724 .with_state(UiState::new(api))
725}