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