Skip to main content

fedimint_server_ui/
setup.rs

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
24// Setup route constants
25pub 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)] // will not be sent if disabled
40    pub enable_base_fees: bool,
41    #[serde(default)] // list of enabled module kinds
42    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
81// GET handler for the /setup route (display the setup form)
82async 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";
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                                                }
203                                            }
204                                        }
205                                    }
206
207                                    div id="modules-warning" class="alert alert-warning mt-2 mb-0" style="font-size: 0.875rem;" {
208                                        "Only modify this if you know what you are doing. Disabled modules cannot be enabled later."
209                                    }
210                                }
211                            }
212                        }
213                    }
214                }
215            }
216
217            div id="setup-error" {}
218
219            div class="button-container" {
220                button type="submit" class="btn btn-primary setup-btn" { "Confirm" }
221            }
222        }
223    };
224
225    Html(setup_layout("Setup Fedimint Guardian", content).into_string()).into_response()
226}
227
228// POST handler for the /setup route (process the password setup form)
229async fn setup_submit(
230    State(state): State<UiState<DynSetupApi>>,
231    Form(input): Form<SetupInput>,
232) -> impl IntoResponse {
233    // Only use these settings if is_lead is true
234    let federation_name = if input.is_lead {
235        Some(input.federation_name)
236    } else {
237        None
238    };
239
240    let disable_base_fees = if input.is_lead {
241        Some(!input.enable_base_fees)
242    } else {
243        None
244    };
245
246    let enabled_modules = if input.is_lead {
247        let enabled: BTreeSet<ModuleKind> = input
248            .enabled_modules
249            .into_iter()
250            .map(|s| ModuleKind::clone_from_str(&s))
251            .collect();
252
253        Some(enabled)
254    } else {
255        None
256    };
257
258    let federation_size = if input.is_lead {
259        let s = input.federation_size.trim();
260        if s.is_empty() {
261            None
262        } else {
263            match s.parse::<u32>() {
264                Ok(size) => Some(size),
265                Err(_) => {
266                    return Html(
267                        html! {
268                            div class="alert alert-danger" { "Invalid federation size" }
269                        }
270                        .into_string(),
271                    )
272                    .into_response();
273                }
274            }
275        }
276    } else {
277        None
278    };
279
280    match state
281        .api
282        .set_local_parameters(
283            ApiAuth(input.password),
284            input.name,
285            federation_name,
286            disable_base_fees,
287            enabled_modules,
288            federation_size,
289        )
290        .await
291    {
292        Ok(_) => ([("HX-Redirect", LOGIN_ROUTE)], Html(String::new())).into_response(),
293        Err(e) => Html(
294            html! {
295                div class="alert alert-danger" { (e.to_string()) }
296            }
297            .into_string(),
298        )
299        .into_response(),
300    }
301}
302
303// GET handler for the /login route (display the login form)
304async fn login_form(State(state): State<UiState<DynSetupApi>>) -> impl IntoResponse {
305    if state.api.setup_code().await.is_none() {
306        return Redirect::to(ROOT_ROUTE).into_response();
307    }
308
309    login_form_response("Fedimint Guardian Login").into_response()
310}
311
312// POST handler for the /login route (authenticate and set session cookie)
313async fn login_submit(
314    State(state): State<UiState<DynSetupApi>>,
315    jar: CookieJar,
316    Form(input): Form<LoginInput>,
317) -> impl IntoResponse {
318    let auth = match state.api.auth().await {
319        Some(auth) => auth,
320        None => return Redirect::to(ROOT_ROUTE).into_response(),
321    };
322
323    login_submit_response(
324        auth,
325        state.auth_cookie_name,
326        state.auth_cookie_value,
327        jar,
328        input,
329    )
330    .into_response()
331}
332
333// GET handler for the /federation-setup route (main federation management page)
334async fn federation_setup(
335    State(state): State<UiState<DynSetupApi>>,
336    _auth: UserAuth,
337) -> impl IntoResponse {
338    let our_connection_info = state
339        .api
340        .setup_code()
341        .await
342        .expect("Successful authentication ensures that the local parameters have been set");
343
344    let connected_peers = state.api.connected_peers().await;
345    let guardian_name = state.api.guardian_name().await;
346    let federation_size = state.api.federation_size().await;
347    let cfg_federation_name = state.api.cfg_federation_name().await;
348    let cfg_base_fees_disabled = state.api.cfg_base_fees_disabled().await;
349    let cfg_enabled_modules = state.api.cfg_enabled_modules().await;
350    let total_guardians = connected_peers.len() + 1;
351    let can_start_dkg = federation_size
352        .map(|expected| total_guardians == expected as usize)
353        .unwrap_or(false);
354
355    let content = html! {
356        @if let Some(ref name) = guardian_name {
357            section class="mb-4" {
358                h4 { "Your name" }
359                p { (name) }
360            }
361        }
362
363        section class="mb-4" {
364            h4 { "Federation settings" }
365            @if cfg_federation_name.is_some() || federation_size.is_some() || cfg_base_fees_disabled.is_some() || cfg_enabled_modules.is_some() {
366                ul class="list-group list-group-flush" {
367                    @if let Some(ref name) = cfg_federation_name {
368                        li class="list-group-item" {
369                            strong { "Federation name: " }
370                            (name)
371                        }
372                    }
373                    @if let Some(size) = federation_size {
374                        li class="list-group-item" {
375                            strong { "Federation size: " }
376                            (size)
377                        }
378                    }
379                    @if let Some(disabled) = cfg_base_fees_disabled {
380                        li class="list-group-item" {
381                            strong { "Base fees: " }
382                            @if disabled { "disabled" } @else { "enabled" }
383                        }
384                    }
385                    @if let Some(ref modules) = cfg_enabled_modules {
386                        li class="list-group-item" {
387                            strong { "Enabled modules: " }
388                            (modules.iter().map(|m| m.as_str().to_owned()).collect::<Vec<_>>().join(", "))
389                        }
390                    }
391                }
392            } @else {
393                p class="text-muted" { "Leader's setup code not provided yet." }
394            }
395        }
396
397        hr class="my-4" {}
398
399        section class="mb-4" {
400            h4 { "Your setup code" }
401
402            p { "Share it with other guardians." }
403
404            @let qr_svg = QrCode::new(&our_connection_info)
405                .expect("Failed to generate QR code")
406                .render::<qrcode::render::svg::Color>()
407                .build();
408
409            div class="text-center mb-3" {
410                div class="border rounded p-2 bg-white d-inline-block" style="width: 250px; max-width: 100%;" {
411                    div style="width: 100%; height: auto; overflow: hidden;" {
412                        (PreEscaped(format!(r#"<div style="width: 100%; height: auto;">{}</div>"#,
413                            qr_svg.replace("width=", "data-width=")
414                                  .replace("height=", "data-height=")
415                                  .replace("<svg", r#"<svg style="width: 100%; height: auto; display: block;""#))))
416                    }
417                }
418            }
419
420            div class="alert alert-info mb-3" {
421                (our_connection_info)
422            }
423
424            div class="text-center" {
425                button type="button" class="btn btn-outline-primary setup-btn"
426                    onclick=(format!("navigator.clipboard.writeText('{}')", our_connection_info)) {
427                    "Copy to Clipboard"
428                }
429            }
430        }
431
432        hr class="my-4" {}
433
434        section class="mb-4" {
435            h4 { "Other guardians" }
436
437            @if let Some(expected) = federation_size {
438                p { (format!("{total_guardians} of {expected} guardians connected.")) }
439            } @else {
440                p { "Add setup code of every other guardian." }
441            }
442
443            ul class="list-group mb-4" {
444                @for peer in connected_peers {
445                    li class="list-group-item" { (peer) }
446                }
447            }
448
449            form method="post" action=(ADD_SETUP_CODE_ROUTE) {
450                div class="mb-3" {
451                    div class="input-group" {
452                        input type="text" class="form-control" id="peer_info" name="peer_info"
453                            placeholder="Paste setup code" required;
454                        button type="button" class="btn btn-outline-secondary" onclick="startQrScanner()" title="Scan QR Code" {
455                            (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>"#))
456                        }
457                    }
458                }
459
460                div class="row mt-3" {
461                    div class="col-6" {
462                        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();}" {
463                            "Reset Guardians"
464                        }
465                    }
466
467                    div class="col-6" {
468                        button type="submit" class="btn btn-primary w-100"
469                            disabled[can_start_dkg] {
470                            @if can_start_dkg { "List complete" } @else { "Add Guardian" }
471                        }
472                    }
473                }
474            }
475
476            form id="reset-form" method="post" action=(RESET_SETUP_CODES_ROUTE) class="d-none" {}
477        }
478
479        hr class="my-4" {}
480
481        section class="mb-4" {
482            div class="alert alert-warning mb-4" {
483                "Verify " b { "all" } " other guardians were added. This process cannot be reversed once started."
484            }
485
486            div class="text-center" {
487                form method="post" action=(START_DKG_ROUTE) {
488                    button type="submit" class="btn btn-warning setup-btn"
489                        disabled[!can_start_dkg] {
490                        "🚀 Confirm"
491                    }
492                }
493                @if !can_start_dkg {
494                    @if let Some(expected) = federation_size {
495                        p class="text-muted mt-2" style="font-size: 0.875rem;" {
496                            (format!("Need to collect {} more setup code(s).", expected as usize - total_guardians))
497                        }
498                    } @else {
499                        p class="text-muted mt-2" style="font-size: 0.875rem;" {
500                            "Need to collect the setup codes from all other guardians."
501                        }
502                    }
503                }
504            }
505        }
506
507        // QR Scanner Modal
508        div class="modal fade" id="qrScannerModal" tabindex="-1" aria-labelledby="qrScannerModalLabel" aria-hidden="true" {
509            div class="modal-dialog modal-dialog-centered" {
510                div class="modal-content" {
511                    div class="modal-header" {
512                        h5 class="modal-title" id="qrScannerModalLabel" { "Scan Setup Code" }
513                        button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
514                    }
515                    div class="modal-body" {
516                        div id="qr-reader" style="width: 100%;" {}
517                        div id="qr-reader-error" class="alert alert-danger mt-3 d-none" {}
518                    }
519                    div class="modal-footer" {
520                        button type="button" class="btn btn-secondary" data-bs-dismiss="modal" { "Cancel" }
521                    }
522                }
523            }
524        }
525
526        // QR Scanner JavaScript
527        script {
528            (PreEscaped(r#"
529            var html5QrCode = null;
530            var qrScannerModal = null;
531
532            function startQrScanner() {
533                // Check for Flutter override hook
534                if (typeof window.fedimintQrScannerOverride === 'function') {
535                    window.fedimintQrScannerOverride(function(result) {
536                        if (result) {
537                            document.getElementById('peer_info').value = result;
538                        }
539                    });
540                    return;
541                }
542
543                var modalEl = document.getElementById('qrScannerModal');
544                qrScannerModal = new bootstrap.Modal(modalEl);
545
546                // Reset error message
547                var errorEl = document.getElementById('qr-reader-error');
548                errorEl.classList.add('d-none');
549                errorEl.textContent = '';
550
551                qrScannerModal.show();
552
553                // Wait for modal to be shown before starting camera
554                modalEl.addEventListener('shown.bs.modal', function onShown() {
555                    modalEl.removeEventListener('shown.bs.modal', onShown);
556                    initializeScanner();
557                });
558
559                // Clean up when modal is hidden
560                modalEl.addEventListener('hidden.bs.modal', function onHidden() {
561                    modalEl.removeEventListener('hidden.bs.modal', onHidden);
562                    stopQrScanner();
563                });
564            }
565
566            function initializeScanner() {
567                html5QrCode = new Html5Qrcode("qr-reader");
568
569                var config = {
570                    fps: 10,
571                    qrbox: { width: 250, height: 250 },
572                    aspectRatio: 1.0
573                };
574
575                html5QrCode.start(
576                    { facingMode: "environment" },
577                    config,
578                    function(decodedText, decodedResult) {
579                        // Success - populate input and close modal
580                        document.getElementById('peer_info').value = decodedText;
581                        qrScannerModal.hide();
582                    },
583                    function(errorMessage) {
584                        // Ignore scan errors (happens constantly while searching)
585                    }
586                ).catch(function(err) {
587                    var errorEl = document.getElementById('qr-reader-error');
588                    errorEl.textContent = 'Unable to access camera: ' + err;
589                    errorEl.classList.remove('d-none');
590                });
591            }
592
593            function stopQrScanner() {
594                if (html5QrCode && html5QrCode.isScanning) {
595                    html5QrCode.stop().catch(function(err) {
596                        console.error('Error stopping scanner:', err);
597                    });
598                }
599            }
600            "#))
601        }
602    };
603
604    Html(setup_layout("Federation Setup", content).into_string()).into_response()
605}
606
607// POST handler for adding peer connection info
608async fn post_add_setup_code(
609    State(state): State<UiState<DynSetupApi>>,
610    _auth: UserAuth,
611    Form(input): Form<PeerInfoInput>,
612) -> impl IntoResponse {
613    match state.api.add_peer_setup_code(input.peer_info).await {
614        Ok(..) => Redirect::to(FEDERATION_SETUP_ROUTE).into_response(),
615        Err(e) => {
616            let content = html! {
617                div class="alert alert-danger" { (e.to_string()) }
618                div class="button-container" {
619                    a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
620                }
621            };
622
623            Html(setup_layout("Error", content).into_string()).into_response()
624        }
625    }
626}
627
628// POST handler for starting the DKG process
629async fn post_start_dkg(
630    State(state): State<UiState<DynSetupApi>>,
631    _auth: UserAuth,
632) -> impl IntoResponse {
633    let our_connection_info = state.api.setup_code().await;
634
635    match state.api.start_dkg().await {
636        Ok(()) => {
637            // Show DKG progress page with htmx polling
638            let content = html! {
639                div class="alert alert-success my-4" {
640                    "Setting up Federation..."
641                }
642
643                p class="text-center" {
644                    "All guardians need to confirm their settings. Once completed you will be redirected to the Dashboard."
645                }
646
647                @if let Some(ref info) = our_connection_info {
648                    hr class="my-4" {}
649                    section class="mb-4" {
650                        h4 { "Your setup code" }
651                        p { "Share with guardians who still need it." }
652                        div class="alert alert-info mb-3" {
653                            (info)
654                        }
655                        div class="text-center" {
656                            button type="button" class="btn btn-outline-primary setup-btn"
657                                onclick=(format!("navigator.clipboard.writeText('{info}')")) {
658                                "Copy to Clipboard"
659                            }
660                        }
661                    }
662                }
663
664                // Hidden div that will poll and redirect when the normal UI is ready
665                div
666                    hx-get=(ROOT_ROUTE)
667                    hx-trigger="every 2s"
668                    hx-swap="none"
669                    hx-on--after-request={
670                        "if (event.detail.xhr.status === 200) { window.location.href = '" (ROOT_ROUTE) "'; }"
671                    }
672                    style="display: none;"
673                {}
674
675                div class="text-center mt-4" {
676                    div class="spinner-border text-primary" role="status" {
677                        span class="visually-hidden" { "Loading..." }
678                    }
679                    p class="mt-2 text-muted" { "Waiting for federation setup to complete..." }
680                }
681            };
682
683            Html(setup_layout("DKG Started", content).into_string()).into_response()
684        }
685        Err(e) => {
686            let content = html! {
687                div class="alert alert-danger" { (e.to_string()) }
688                div class="button-container" {
689                    a href=(FEDERATION_SETUP_ROUTE) class="btn btn-primary setup-btn" { "Return to Setup" }
690                }
691            };
692
693            Html(setup_layout("Error", content).into_string()).into_response()
694        }
695    }
696}
697
698// POST handler for resetting peer connection info
699async fn post_reset_setup_codes(
700    State(state): State<UiState<DynSetupApi>>,
701    _auth: UserAuth,
702) -> impl IntoResponse {
703    state.api.reset_setup_codes().await;
704
705    Redirect::to(FEDERATION_SETUP_ROUTE).into_response()
706}
707
708pub fn router(api: DynSetupApi) -> Router {
709    Router::new()
710        .route(ROOT_ROUTE, get(setup_form).post(setup_submit))
711        .route(LOGIN_ROUTE, get(login_form).post(login_submit))
712        .route(FEDERATION_SETUP_ROUTE, get(federation_setup))
713        .route(ADD_SETUP_CODE_ROUTE, post(post_add_setup_code))
714        .route(RESET_SETUP_CODES_ROUTE, post(post_reset_setup_codes))
715        .route(START_DKG_ROUTE, post(post_start_dkg))
716        .route(
717            CONNECTIVITY_CHECK_ROUTE,
718            get(connectivity_check_handler::<DynSetupApi>),
719        )
720        .with_static_routes()
721        .with_state(UiState::new(api))
722}