Skip to main content

fedimint_gateway_ui/
federation.rs

1use std::collections::BTreeMap;
2use std::fmt::Display;
3use std::str::FromStr;
4use std::time::{Duration, SystemTime};
5
6use axum::Form;
7use axum::extract::{Path, State};
8use axum::response::{Html, IntoResponse};
9use bitcoin::Address;
10use bitcoin::address::NetworkUnchecked;
11use fedimint_core::config::FederationId;
12use fedimint_core::invite_code::InviteCode;
13use fedimint_core::{Amount, BitcoinAmountOrAll, PeerId, TieredCounts};
14use fedimint_gateway_common::{
15    DepositAddressPayload, FederationInfo, LeaveFedPayload, ReceiveEcashPayload, SetFeesPayload,
16    SpendEcashPayload, WithdrawPayload, WithdrawPreviewPayload,
17};
18use fedimint_mint_client::OOBNotes;
19use fedimint_ui_common::UiState;
20use fedimint_ui_common::auth::UserAuth;
21use fedimint_wallet_client::PegOutFees;
22use maud::{Markup, PreEscaped, html};
23use qrcode::QrCode;
24use qrcode::render::svg;
25use serde::Deserialize;
26
27use crate::{
28    DEPOSIT_ADDRESS_ROUTE, DynGatewayApi, RECEIVE_ECASH_ROUTE, SET_FEES_ROUTE, SPEND_ECASH_ROUTE,
29    WITHDRAW_CONFIRM_ROUTE, WITHDRAW_PREVIEW_ROUTE, redirect_error, redirect_success,
30    redirect_success_with_export_reminder,
31};
32
33#[derive(Deserialize)]
34pub struct ReceiveEcashForm {
35    pub notes: String,
36    #[serde(default)]
37    pub wait: bool,
38}
39
40pub fn scripts() -> Markup {
41    html!(
42        script {
43            (PreEscaped(r#"
44            function toggleFeesEdit(id) {
45                const viewDiv = document.getElementById('fees-view-' + id);
46                const editDiv = document.getElementById('fees-edit-' + id);
47                if (viewDiv.style.display === 'none') {
48                    viewDiv.style.display = '';
49                    editDiv.style.display = 'none';
50                } else {
51                    viewDiv.style.display = 'none';
52                    editDiv.style.display = '';
53                }
54            }
55
56            function copyToClipboard(input) {
57                input.select();
58                document.execCommand('copy');
59                const hint = input.nextElementSibling;
60                hint.textContent = 'Copied!';
61                setTimeout(() => hint.textContent = 'Click to copy', 2000);
62            }
63
64            function copyText(input) {
65                input.select();
66                document.execCommand('copy');
67                input.style.outline = '2px solid #28a745';
68                setTimeout(() => input.style.outline = '', 1500);
69            }
70
71            // Initialize Bootstrap tooltips
72            document.addEventListener('DOMContentLoaded', function() {
73                var tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
74                tooltipTriggerList.forEach(function(el) {
75                    new bootstrap.Tooltip(el);
76                });
77            });
78
79            // Format amount based on selected unit
80            function formatAmount(msats, unit) {
81                if (unit === 'btc') {
82                    const btc = msats / 100000000000;
83                    return btc.toFixed(8) + ' BTC';
84                } else if (unit === 'sats') {
85                    const sats = msats / 1000;
86                    if (Number.isInteger(sats)) {
87                        return sats.toLocaleString() + ' sats';
88                    }
89                    return sats.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 3}) + ' sats';
90                } else {
91                    return msats.toLocaleString() + ' msats';
92                }
93            }
94
95            // Update all amount displays within a federation card
96            function updateAmounts(fedId, unit) {
97                const card = document.getElementById('fed-card-' + fedId);
98                if (!card) return;
99
100                card.querySelectorAll('[data-msats]').forEach(function(el) {
101                    const msats = parseInt(el.getAttribute('data-msats'), 10);
102                    const display = el.querySelector('.amount-display');
103                    if (display) {
104                        display.textContent = formatAmount(msats, unit);
105                    }
106                });
107            }
108
109            // Get currently selected unit for a federation
110            function getSelectedUnit(fedId) {
111                const checked = document.querySelector('input[name="unit-' + fedId + '"]:checked');
112                if (checked) {
113                    return checked.value;
114                }
115                return 'btc';
116            }
117
118            // Initialize unit toggle listeners
119            document.addEventListener('DOMContentLoaded', function() {
120                document.querySelectorAll('.unit-toggle').forEach(function(toggle) {
121                    const fedId = toggle.getAttribute('data-fed-id');
122                    toggle.querySelectorAll('input[type="radio"]').forEach(function(radio) {
123                        radio.addEventListener('change', function(e) {
124                            updateAmounts(fedId, e.target.value);
125                        });
126                    });
127                });
128            });
129
130            // Re-apply unit formatting after HTMX swaps
131            document.addEventListener('htmx:afterSwap', function(evt) {
132                // Find the federation card this swap belongs to
133                const card = evt.target.closest('[id^="fed-card-"]');
134                if (card) {
135                    const fedId = card.id.replace('fed-card-', '');
136                    const unit = getSelectedUnit(fedId);
137                    updateAmounts(fedId, unit);
138                }
139            });
140            "#))
141        }
142    )
143}
144
145pub fn render<E: Display>(
146    fed: &FederationInfo,
147    invite_codes: &BTreeMap<PeerId, (String, InviteCode)>,
148    note_summary: &Result<TieredCounts, E>,
149) -> Markup {
150    html!(
151        @let bal = fed.balance_msat;
152        @let balance_class = if bal == Amount::ZERO {
153            "alert alert-danger"
154        } else {
155            "alert alert-success"
156        };
157        @let last_backup_str = fed.last_backup_time
158            .map(time_ago)
159            .unwrap_or("Never".to_string());
160
161
162        @let fed_id_str = fed.federation_id.to_string();
163        @let btc_value = fed.balance_msat.msats as f64 / 100_000_000_000.0;
164
165        div class="row gy-4 mt-2" {
166            div class="col-12" {
167                div class="card h-100" id=(format!("fed-card-{}", fed_id_str)) {
168                    div class="card-header dashboard-header d-flex justify-content-between align-items-center" {
169                        div {
170                            (fed.federation_name.clone().unwrap_or("Unnamed Federation".to_string()))
171                        }
172
173                        div class="d-flex align-items-center gap-2" {
174                            // Unit toggle
175                            div class="btn-group btn-group-sm unit-toggle" role="group" data-fed-id=(fed_id_str) {
176                                input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-btc-{}", fed_id_str)) value="btc" checked;
177                                label class="btn btn-outline-primary" for=(format!("unit-btc-{}", fed_id_str)) { "BTC" }
178
179                                input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-sats-{}", fed_id_str)) value="sats";
180                                label class="btn btn-outline-primary" for=(format!("unit-sats-{}", fed_id_str)) { "sats" }
181
182                                input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-msats-{}", fed_id_str)) value="msats";
183                                label class="btn btn-outline-primary" for=(format!("unit-msats-{}", fed_id_str)) { "msats" }
184                            }
185
186                            form method="post" action={(format!("/ui/federations/{}/leave", fed.federation_id))} {
187                                button type="submit"
188                                    class="btn btn-outline-danger btn-sm"
189                                    title="Leave Federation"
190                                    onclick=("return confirm('Are you sure you want to leave this federation? You will need to re-connect the federation to access any remaining balance.');")
191                                { "📤" }
192                            }
193                        }
194                    }
195                    div class="card-body" {
196                        div id=(format!("balance-{}", fed.federation_id)) class=(balance_class) data-msats=(fed.balance_msat.msats) {
197                            "Balance: " strong class="amount-display" { (format!("{:.8} BTC", btc_value)) }
198                        }
199                        div class="alert alert-secondary py-1 px-2 small" {
200                            "Last Backup: " strong { (last_backup_str) }
201                        }
202
203                        // --- TABS ---
204                        ul class="nav nav-tabs" role="tablist" {
205                            li class="nav-item" role="presentation" {
206                                button class="nav-link active"
207                                    id={(format!("fees-tab-{}", fed.federation_id))}
208                                    data-bs-toggle="tab"
209                                    data-bs-target={(format!("#fees-tab-pane-{}", fed.federation_id))}
210                                    type="button"
211                                    role="tab"
212                                { "Fees" }
213                            }
214                            li class="nav-item" role="presentation" {
215                                button class="nav-link"
216                                    id={(format!("deposit-tab-{}", fed.federation_id))}
217                                    data-bs-toggle="tab"
218                                    data-bs-target={(format!("#deposit-tab-pane-{}", fed.federation_id))}
219                                    type="button"
220                                    role="tab"
221                                { "Deposit" }
222                            }
223                            li class="nav-item" role="presentation" {
224                                button class="nav-link"
225                                    id={(format!("withdraw-tab-{}", fed.federation_id))}
226                                    data-bs-toggle="tab"
227                                    data-bs-target={(format!("#withdraw-tab-pane-{}", fed.federation_id))}
228                                    type="button"
229                                    role="tab"
230                                { "Withdraw" }
231                            }
232                            li class="nav-item" role="presentation" {
233                                button class="nav-link"
234                                    id={(format!("spend-tab-{}", fed.federation_id))}
235                                    data-bs-toggle="tab"
236                                    data-bs-target={(format!("#spend-tab-pane-{}", fed.federation_id))}
237                                    type="button"
238                                    role="tab"
239                                { "Spend" }
240                            }
241                            li class="nav-item" role="presentation" {
242                                button class="nav-link"
243                                    id=(format!("receive-tab-{}", fed.federation_id))
244                                    data-bs-toggle="tab"
245                                    data-bs-target=(format!("#receive-tab-pane-{}", fed.federation_id))
246                                    type="button"
247                                    role="tab"
248                                { "Receive" }
249                            }
250                            li class="nav-item" role="presentation" {
251                                button class="nav-link"
252                                    id=(format!("peers-tab-{}", fed.federation_id))
253                                    data-bs-toggle="tab"
254                                    data-bs-target=(format!("#peers-tab-pane-{}", fed.federation_id))
255                                    type="button"
256                                    role="tab"
257                                { "Peers" }
258                            }
259                            li class="nav-item" role="presentation" {
260                                button class="nav-link"
261                                    id=(format!("notes-tab-{}", fed.federation_id))
262                                    data-bs-toggle="tab"
263                                    data-bs-target=(format!("#notes-tab-pane-{}", fed.federation_id))
264                                    type="button"
265                                    role="tab"
266                                { "Notes" }
267                            }
268                        }
269
270                        div class="tab-content mt-3" {
271
272                            // ──────────────────────────────────────────
273                            //   TAB: FEES
274                            // ──────────────────────────────────────────
275                            div class="tab-pane fade show active"
276                                id={(format!("fees-tab-pane-{}", fed.federation_id))}
277                                role="tabpanel"
278                                aria-labelledby={(format!("fees-tab-{}", fed.federation_id))} {
279
280                                // READ-ONLY VERSION
281                                div id={(format!("fees-view-{}", fed.federation_id))} {
282                                    table class="table table-sm mb-2" {
283                                        tbody {
284                                            tr {
285                                                th {
286                                                    "Lightning Base Fee "
287                                                    span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
288                                                }
289                                                td { (fed.config.lightning_fee.base) }
290                                            }
291                                            tr {
292                                                th {
293                                                    "Lightning PPM "
294                                                    span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
295                                                }
296                                                td { (fed.config.lightning_fee.parts_per_million) }
297                                            }
298                                            tr {
299                                                th {
300                                                    "Transaction Base Fee "
301                                                    span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
302                                                }
303                                                td { (fed.config.transaction_fee.base) }
304                                            }
305                                            tr {
306                                                th {
307                                                    "Transaction PPM "
308                                                    span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
309                                                }
310                                                td { (fed.config.transaction_fee.parts_per_million) }
311                                            }
312                                        }
313                                    }
314
315                                    button
316                                        class="btn btn-sm btn-outline-primary"
317                                        type="button"
318                                        onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
319                                    {
320                                        "Edit Fees"
321                                    }
322                                }
323
324                                // EDIT FORM (HIDDEN INITIALLY)
325                                div id={(format!("fees-edit-{}", fed.federation_id))} style="display: none;" {
326                                    form
327                                        method="post"
328                                        action={(SET_FEES_ROUTE)}
329                                    {
330                                        input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
331                                        table class="table table-sm mb-2" {
332                                            tbody {
333                                                tr {
334                                                    th {
335                                                        "Lightning Base Fee "
336                                                        span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
337                                                    }
338                                                    td {
339                                                        input type="number"
340                                                            class="form-control form-control-sm"
341                                                            name="lightning_base"
342                                                            value=(fed.config.lightning_fee.base.msats);
343                                                    }
344                                                }
345                                                tr {
346                                                    th {
347                                                        "Lightning PPM "
348                                                        span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
349                                                    }
350                                                    td {
351                                                        input type="number"
352                                                            class="form-control form-control-sm"
353                                                            name="lightning_parts_per_million"
354                                                            value=(fed.config.lightning_fee.parts_per_million);
355                                                    }
356                                                }
357                                                tr {
358                                                    th {
359                                                        "Transaction Base Fee "
360                                                        span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
361                                                    }
362                                                    td {
363                                                        input type="number"
364                                                            class="form-control form-control-sm"
365                                                            name="transaction_base"
366                                                            value=(fed.config.transaction_fee.base.msats);
367                                                    }
368                                                }
369                                                tr {
370                                                    th {
371                                                        "Transaction PPM "
372                                                        span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
373                                                    }
374                                                    td {
375                                                        input type="number"
376                                                            class="form-control form-control-sm"
377                                                            name="transaction_parts_per_million"
378                                                            value=(fed.config.transaction_fee.parts_per_million);
379                                                    }
380                                                }
381                                            }
382                                        }
383
384                                        button type="submit" class="btn btn-sm btn-primary me-2" { "Save Fees" }
385                                        button
386                                            type="button"
387                                            class="btn btn-sm btn-secondary"
388                                            onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
389                                        {
390                                            "Cancel"
391                                        }
392                                    }
393                                }
394                            }
395
396                            // ──────────────────────────────────────────
397                            //   TAB: DEPOSIT
398                            // ──────────────────────────────────────────
399                            div class="tab-pane fade"
400                                id={(format!("deposit-tab-pane-{}", fed.federation_id))}
401                                role="tabpanel"
402                                aria-labelledby={(format!("deposit-tab-{}", fed.federation_id))} {
403
404                                form hx-post=(DEPOSIT_ADDRESS_ROUTE)
405                                     hx-target={(format!("#deposit-result-{}", fed.federation_id))}
406                                     hx-swap="innerHTML"
407                                {
408                                    input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
409                                    button type="submit"
410                                        class="btn btn-outline-primary btn-sm"
411                                    {
412                                        "New Deposit Address"
413                                    }
414                                }
415
416                                div id=(format!("deposit-result-{}", fed.federation_id)) {}
417                            }
418
419                            // ──────────────────────────────────────────
420                            //   TAB: WITHDRAW
421                            // ──────────────────────────────────────────
422                            div class="tab-pane fade"
423                                id={(format!("withdraw-tab-pane-{}", fed.federation_id))}
424                                role="tabpanel"
425                                aria-labelledby={(format!("withdraw-tab-{}", fed.federation_id))} {
426
427                                form hx-post=(WITHDRAW_PREVIEW_ROUTE)
428                                     hx-target={(format!("#withdraw-result-{}", fed.federation_id))}
429                                     hx-swap="innerHTML"
430                                     class="mt-3"
431                                     id=(format!("withdraw-form-{}", fed.federation_id))
432                                {
433                                    input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
434
435                                    div class="mb-3" {
436                                        label class="form-label" for=(format!("withdraw-amount-{}", fed.federation_id)) { "Amount (sats or 'all')" }
437                                        input type="text"
438                                            class="form-control"
439                                            id=(format!("withdraw-amount-{}", fed.federation_id))
440                                            name="amount"
441                                            placeholder="e.g. 100000 or all"
442                                            required;
443                                    }
444
445                                    div class="mb-3" {
446                                        label class="form-label" for=(format!("withdraw-address-{}", fed.federation_id)) { "Bitcoin Address" }
447                                        input type="text"
448                                            class="form-control"
449                                            id=(format!("withdraw-address-{}", fed.federation_id))
450                                            name="address"
451                                            placeholder="bc1q..."
452                                            required;
453                                    }
454
455                                    button type="submit" class="btn btn-primary" { "Preview" }
456                                }
457
458                                div id=(format!("withdraw-result-{}", fed.federation_id)) class="mt-3" {}
459                            }
460
461                            // ──────────────────────────────────────────
462                            //   TAB: SPEND
463                            // ──────────────────────────────────────────
464                            div class="tab-pane fade"
465                                id={(format!("spend-tab-pane-{}", fed.federation_id))}
466                                role="tabpanel"
467                                aria-labelledby={(format!("spend-tab-{}", fed.federation_id))} {
468
469                                form hx-post=(SPEND_ECASH_ROUTE)
470                                     hx-target={(format!("#spend-result-{}", fed.federation_id))}
471                                     hx-swap="innerHTML"
472                                {
473                                    input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
474
475                                    // Amount input (required)
476                                    div class="mb-3" {
477                                        label class="form-label" for={(format!("spend-amount-{}", fed.federation_id))} {
478                                            "Amount (msats)"
479                                        }
480                                        input type="number"
481                                            class="form-control"
482                                            id={(format!("spend-amount-{}", fed.federation_id))}
483                                            name="amount"
484                                            placeholder="1000"
485                                            min="1"
486                                            required;
487                                    }
488
489                                    button type="submit" class="btn btn-primary" { "Generate Ecash" }
490                                }
491
492                                div id=(format!("spend-result-{}", fed.federation_id)) class="mt-3" {}
493                            }
494
495                            // ──────────────────────────────────────────
496                            //   TAB: RECEIVE
497                            // ──────────────────────────────────────────
498                            div class="tab-pane fade"
499                                id=(format!("receive-tab-pane-{}", fed.federation_id))
500                                role="tabpanel"
501                                aria-labelledby=(format!("receive-tab-{}", fed.federation_id)) {
502
503                                form hx-post=(RECEIVE_ECASH_ROUTE)
504                                     hx-target=(format!("#receive-result-{}", fed.federation_id))
505                                     hx-swap="innerHTML"
506                                {
507                                    input type="hidden" name="wait" value="true";
508
509                                    div class="mb-3" {
510                                        label class="form-label" for=(format!("receive-notes-{}", fed.federation_id)) {
511                                            "Ecash Notes"
512                                        }
513                                        textarea
514                                            class="form-control font-monospace"
515                                            id=(format!("receive-notes-{}", fed.federation_id))
516                                            name="notes"
517                                            rows="4"
518                                            placeholder="Paste ecash string here..."
519                                            required {}
520                                    }
521
522                                    button type="submit" class="btn btn-primary" { "Receive Ecash" }
523                                }
524
525                                div id=(format!("receive-result-{}", fed.federation_id)) class="mt-3" {}
526                            }
527
528                            // ──────────────────────────────────────────
529                            //   TAB: PEERS
530                            // ──────────────────────────────────────────
531                            div class="tab-pane fade"
532                                id=(format!("peers-tab-pane-{}", fed.federation_id))
533                                role="tabpanel"
534                                aria-labelledby=(format!("peers-tab-{}", fed.federation_id))
535                            {
536                                @if invite_codes.is_empty() {
537                                    div class="alert alert-secondary" {
538                                        "No invite codes found for this federation."
539                                    }
540                                } @else {
541                                    table class="table table-sm" {
542                                        thead {
543                                            tr {
544                                                th { "Peer ID" }
545                                                th { "Name" }
546                                                th { "Invite Code" }
547                                            }
548                                        }
549                                        tbody {
550                                            @for (peer_id, (name, code)) in invite_codes {
551                                                @let code_str = code.to_string();
552                                                @let modal_id = format!("qr-modal-{}-{}", fed.federation_id, peer_id);
553                                                @let qr = QrCode::new(code_str.as_bytes()).expect("Failed to generate QR code");
554                                                @let qr_svg = qr.render::<svg::Color>().build();
555                                                tr {
556                                                    td { (peer_id) }
557                                                    td { (name) }
558                                                    td {
559                                                        div class="d-flex align-items-center gap-1" {
560                                                            input type="text"
561                                                                class="form-control form-control-sm"
562                                                                value=(code_str)
563                                                                readonly
564                                                                onclick="copyText(this)"
565                                                                style="cursor: pointer; font-size: 0.75rem;";
566                                                            button type="button"
567                                                                class="btn btn-sm btn-outline-secondary"
568                                                                data-bs-toggle="modal"
569                                                                data-bs-target=(format!("#{}", modal_id))
570                                                                title="Show QR Code"
571                                                            { "QR" }
572                                                        }
573
574                                                        // QR Code Modal
575                                                        div class="modal fade"
576                                                            id=(modal_id)
577                                                            tabindex="-1"
578                                                            aria-hidden="true"
579                                                        {
580                                                            div class="modal-dialog modal-dialog-centered" {
581                                                                div class="modal-content" {
582                                                                    div class="modal-header" {
583                                                                        h5 class="modal-title" {
584                                                                            "Invite Code — Peer " (peer_id) " (" (name) ")"
585                                                                        }
586                                                                        button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
587                                                                    }
588                                                                    div class="modal-body d-flex justify-content-center" {
589                                                                        div class="border rounded p-2 bg-white"
590                                                                            style="width: 300px; height: 300px;"
591                                                                        {
592                                                                            (PreEscaped(format!(
593                                                                                r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
594                                                                                qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
595                                                                            )))
596                                                                        }
597                                                                    }
598                                                                }
599                                                            }
600                                                        }
601                                                    }
602                                                }
603                                            }
604                                        }
605                                    }
606                                }
607                            }
608
609                            // ──────────────────────────────────────────
610                            //   TAB: NOTES
611                            // ──────────────────────────────────────────
612                            div class="tab-pane fade"
613                                id=(format!("notes-tab-pane-{}", fed.federation_id))
614                                role="tabpanel"
615                                aria-labelledby=(format!("notes-tab-{}", fed.federation_id))
616                            {
617                                @match &note_summary {
618                                    Ok(notes) => {
619                                        @if notes.is_empty() {
620                                            div class="alert alert-secondary" {
621                                                "No notes in wallet."
622                                            }
623                                        } @else {
624                                            table class="table table-sm table-bordered mb-0" {
625                                                thead {
626                                                    tr class="table-light" {
627                                                        th { "Denomination" }
628                                                        th { "Count" }
629                                                        th { "Subtotal" }
630                                                    }
631                                                }
632                                                tbody {
633                                                    @for (denomination, count) in notes.iter() {
634                                                        tr {
635                                                            td { (denomination) }
636                                                            td { (count) }
637                                                            td { (denomination * (count as u64)) }
638                                                        }
639                                                    }
640                                                }
641                                                tfoot {
642                                                    tr class="table-light fw-bold" {
643                                                        td { "Total" }
644                                                        td { (notes.count_items()) }
645                                                        td { (notes.total_amount()) }
646                                                    }
647                                                }
648                                            }
649                                        }
650                                    }
651                                    Err(err) => {
652                                        div class="alert alert-warning" {
653                                            "Could not load note summary: " (err)
654                                        }
655                                    }
656                                }
657                            }
658                        }
659                    }
660                }
661            }
662        }
663    )
664}
665
666fn time_ago(t: SystemTime) -> String {
667    let now = fedimint_core::time::now();
668    let diff = match now.duration_since(t) {
669        Ok(d) => d,
670        Err(_) => Duration::from_secs(0),
671    };
672
673    let secs = diff.as_secs();
674
675    match secs {
676        0..=59 => format!("{} seconds ago", secs),
677        60..=3599 => format!("{} minutes ago", secs / 60),
678        _ => format!("{} hours ago", secs / 3600),
679    }
680}
681
682pub async fn leave_federation_handler<E: Display>(
683    State(state): State<UiState<DynGatewayApi<E>>>,
684    Path(id): Path<String>,
685    _auth: UserAuth,
686) -> impl IntoResponse {
687    let federation_id = FederationId::from_str(&id);
688    if let Ok(federation_id) = federation_id {
689        match state
690            .api
691            .handle_leave_federation(LeaveFedPayload { federation_id })
692            .await
693        {
694            Ok(info) => {
695                // Redirect back to dashboard after success
696                redirect_success_with_export_reminder(format!(
697                    "Successfully left {}.",
698                    info.federation_name
699                        .unwrap_or("Unnamed Federation".to_string())
700                ))
701                .into_response()
702            }
703            Err(err) => {
704                redirect_error(format!("Failed to leave federation: {err}")).into_response()
705            }
706        }
707    } else {
708        redirect_error("Failed to leave federation: Invalid federation id".to_string())
709            .into_response()
710    }
711}
712
713pub async fn set_fees_handler<E: Display>(
714    State(state): State<UiState<DynGatewayApi<E>>>,
715    _auth: UserAuth,
716    Form(payload): Form<SetFeesPayload>,
717) -> impl IntoResponse {
718    tracing::info!("Received fees payload: {:?}", payload);
719
720    match state.api.handle_set_fees_msg(payload).await {
721        Ok(_) => redirect_success("Successfully set fees".to_string()).into_response(),
722        Err(err) => redirect_error(format!("Failed to update fees: {err}")).into_response(),
723    }
724}
725
726pub async fn deposit_address_handler<E: Display>(
727    State(state): State<UiState<DynGatewayApi<E>>>,
728    _auth: UserAuth,
729    Form(payload): Form<DepositAddressPayload>,
730) -> impl IntoResponse {
731    let markup = match state.api.handle_deposit_address_msg(payload).await {
732        Ok(address) => {
733            let code =
734                QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
735            let qr_svg = code.render::<svg::Color>().build();
736
737            html! {
738                div class="card card-body bg-light d-flex flex-column align-items-center mt-2" {
739                    span class="fw-bold mb-3" { "Deposit Address:" }
740
741                    div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
742
743                        // Copyable input + text
744                        div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
745                            input type="text"
746                                readonly
747                                class="form-control mb-2"
748                                style="text-align:left; font-family: monospace; font-size:1rem;"
749                                value=(address)
750                                onclick="copyToClipboard(this)"
751                            {}
752                            small class="text-muted" { "Click to copy" }
753                        }
754
755                        // QR code
756                        div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
757                            style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
758                        {
759                            (PreEscaped(format!(
760                                r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
761                                qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
762                            )))
763                        }
764                    }
765                }
766            }
767        }
768        Err(err) => {
769            html! {
770                div class="alert alert-danger mt-2" {
771                    "Failed to generate deposit address: " (err)
772                }
773            }
774        }
775    };
776    Html(markup.into_string())
777}
778
779/// Preview handler for two-step withdrawal flow - shows fee breakdown before
780/// confirmation
781pub async fn withdraw_preview_handler<E: Display>(
782    State(state): State<UiState<DynGatewayApi<E>>>,
783    _auth: UserAuth,
784    Form(payload): Form<WithdrawPreviewPayload>,
785) -> impl IntoResponse {
786    let federation_id = payload.federation_id;
787    let is_max = matches!(payload.amount, BitcoinAmountOrAll::All);
788
789    let markup = match state.api.handle_withdraw_preview_msg(payload).await {
790        Ok(response) => {
791            let amount_label = if is_max {
792                format!("{} sats (max)", response.withdraw_amount.sats_round_down())
793            } else {
794                format!("{} sats", response.withdraw_amount.sats_round_down())
795            };
796
797            html! {
798                div class="card" {
799                    div class="card-body" {
800                        h6 class="card-title" { "Withdrawal Preview" }
801
802                        table class="table table-sm" {
803                            tbody {
804                                tr {
805                                    td { "Amount" }
806                                    td { (amount_label) }
807                                }
808                                tr {
809                                    td { "Address" }
810                                    td class="text-break" style="font-family: monospace; font-size: 0.85em;" {
811                                        (response.address.clone())
812                                    }
813                                }
814                                tr {
815                                    td { "Fee Rate" }
816                                    td { (format!("{} sats/kvB", response.peg_out_fees.fee_rate.sats_per_kvb)) }
817                                }
818                                tr {
819                                    td { "Transaction Size" }
820                                    td { (format!("{} weight units", response.peg_out_fees.total_weight)) }
821                                }
822                                tr {
823                                    td { "Peg-out Fee" }
824                                    td { (format!("{} sats", response.peg_out_fees.amount().to_sat())) }
825                                }
826                                @if let Some(mint_fee) = response.mint_fees {
827                                    tr {
828                                        td { "Mint Fee (est.)" }
829                                        td { (format!("~{} sats", mint_fee.sats_round_down())) }
830                                    }
831                                }
832                                tr {
833                                    td { strong { "Total Deducted" } }
834                                    td { strong { (format!("{} sats", response.total_cost.sats_round_down())) } }
835                                }
836                            }
837                        }
838
839                        div class="d-flex gap-2 mt-3" {
840                            // Confirm form with hidden fields
841                            form hx-post=(WITHDRAW_CONFIRM_ROUTE)
842                                 hx-target=(format!("#withdraw-result-{}", federation_id))
843                                 hx-swap="innerHTML"
844                            {
845                                input type="hidden" name="federation_id" value=(federation_id.to_string());
846                                input type="hidden" name="amount" value=(response.withdraw_amount.sats_round_down().to_string());
847                                input type="hidden" name="address" value=(response.address);
848                                input type="hidden" name="fee_rate_sats_per_kvb" value=(response.peg_out_fees.fee_rate.sats_per_kvb.to_string());
849                                input type="hidden" name="total_weight" value=(response.peg_out_fees.total_weight.to_string());
850
851                                button type="submit" class="btn btn-success" { "Confirm Withdrawal" }
852                            }
853
854                            // Cancel button - clears the result area
855                            button type="button"
856                                   class="btn btn-outline-secondary"
857                                   onclick=(format!("document.getElementById('withdraw-result-{}').innerHTML = ''", federation_id))
858                            { "Cancel" }
859                        }
860                    }
861                }
862            }
863        }
864        Err(err) => {
865            html! {
866                div class="alert alert-danger" {
867                    "Error: " (err.to_string())
868                }
869            }
870        }
871    };
872    Html(markup.into_string())
873}
874
875/// Payload for withdraw confirmation from the UI
876#[derive(Debug, serde::Deserialize)]
877pub struct WithdrawConfirmPayload {
878    pub federation_id: FederationId,
879    pub amount: u64,
880    pub address: String,
881    pub fee_rate_sats_per_kvb: u64,
882    pub total_weight: u64,
883}
884
885/// Confirm handler for two-step withdrawal flow - executes withdrawal with
886/// quoted fees
887pub async fn withdraw_confirm_handler<E: Display>(
888    State(state): State<UiState<DynGatewayApi<E>>>,
889    _auth: UserAuth,
890    Form(payload): Form<WithdrawConfirmPayload>,
891) -> impl IntoResponse {
892    let federation_id = payload.federation_id;
893
894    // Parse the address - it should already be validated from the preview step
895    let address: Address<NetworkUnchecked> = match payload.address.parse() {
896        Ok(addr) => addr,
897        Err(err) => {
898            return Html(
899                html! {
900                    div class="alert alert-danger" {
901                        "Error parsing address: " (err.to_string())
902                    }
903                }
904                .into_string(),
905            );
906        }
907    };
908
909    // Build the WithdrawPayload with the quoted fees
910    let withdraw_payload = WithdrawPayload {
911        federation_id,
912        amount: BitcoinAmountOrAll::Amount(bitcoin::Amount::from_sat(payload.amount)),
913        address,
914        quoted_fees: Some(PegOutFees::new(
915            payload.fee_rate_sats_per_kvb,
916            payload.total_weight,
917        )),
918    };
919
920    let markup = match state.api.handle_withdraw_msg(withdraw_payload).await {
921        Ok(response) => {
922            // Fetch updated balance for the out-of-band swap
923            let updated_balance = state
924                .api
925                .handle_get_balances_msg()
926                .await
927                .ok()
928                .and_then(|balances| {
929                    balances
930                        .ecash_balances
931                        .into_iter()
932                        .find(|b| b.federation_id == federation_id)
933                        .map(|b| b.ecash_balance_msats)
934                })
935                .unwrap_or(Amount::ZERO);
936
937            let balance_class = if updated_balance == Amount::ZERO {
938                "alert alert-danger"
939            } else {
940                "alert alert-success"
941            };
942
943            let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
944
945            html! {
946                // Success message (swaps into result div)
947                div class="alert alert-success" {
948                    p { strong { "Withdrawal successful!" } }
949                    p { "Transaction ID: " code { (response.txid) } }
950                    p { "Peg-out Fee: " (format!("{} sats", response.fees.amount().to_sat())) }
951                }
952
953                // Out-of-band swap to update balance banner
954                div id=(format!("balance-{}", federation_id))
955                    class=(balance_class)
956                    data-msats=(updated_balance.msats)
957                    hx-swap-oob="true"
958                {
959                    "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
960                }
961            }
962        }
963        Err(err) => {
964            html! {
965                div class="alert alert-danger" {
966                    "Error: " (err.to_string())
967                }
968            }
969        }
970    };
971    Html(markup.into_string())
972}
973
974pub async fn spend_ecash_handler<E: Display>(
975    State(state): State<UiState<DynGatewayApi<E>>>,
976    _auth: UserAuth,
977    Form(payload): Form<SpendEcashPayload>,
978) -> impl IntoResponse {
979    let federation_id = payload.federation_id;
980    let requested_amount = payload.amount;
981
982    let markup = match state.api.handle_spend_ecash_msg(payload).await {
983        Ok(response) => {
984            let notes_string = response.notes.to_string();
985            let actual_amount = response.notes.total_amount();
986            let overspent = actual_amount > requested_amount;
987
988            // Fetch updated balance for the out-of-band swap
989            let updated_balance = state
990                .api
991                .handle_get_balances_msg()
992                .await
993                .ok()
994                .and_then(|balances| {
995                    balances
996                        .ecash_balances
997                        .into_iter()
998                        .find(|b| b.federation_id == federation_id)
999                        .map(|b| b.ecash_balance_msats)
1000                })
1001                .unwrap_or(Amount::ZERO);
1002
1003            let balance_class = if updated_balance == Amount::ZERO {
1004                "alert alert-danger"
1005            } else {
1006                "alert alert-success"
1007            };
1008
1009            let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1010
1011            html! {
1012                div class="card card-body bg-light" {
1013                    div class="d-flex justify-content-between align-items-center mb-2" {
1014                        span class="fw-bold" { "Ecash Generated" }
1015                        span class="badge bg-success" { (actual_amount) }
1016                    }
1017
1018                    @if overspent {
1019                        div class="alert alert-warning py-2 mb-2" {
1020                            "Note: Spent " (actual_amount) " ("
1021                            (actual_amount.saturating_sub(requested_amount))
1022                            " more than requested due to note denominations)"
1023                        }
1024                    }
1025
1026                    div class="mb-2" {
1027                        label class="form-label small text-muted" { "Ecash Notes (click to copy):" }
1028                        textarea
1029                            class="form-control font-monospace"
1030                            rows="4"
1031                            readonly
1032                            onclick="copyToClipboard(this)"
1033                            style="font-size: 0.85rem;"
1034                        { (notes_string) }
1035                        small class="text-muted" { "Click to copy" }
1036                    }
1037                }
1038
1039                // Out-of-band swap to update balance banner
1040                div id=(format!("balance-{}", federation_id))
1041                    class=(balance_class)
1042                    data-msats=(updated_balance.msats)
1043                    hx-swap-oob="true"
1044                {
1045                    "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1046                }
1047            }
1048        }
1049        Err(err) => {
1050            html! {
1051                div class="alert alert-danger" {
1052                    "Failed to generate ecash: " (err)
1053                }
1054            }
1055        }
1056    };
1057    Html(markup.into_string())
1058}
1059
1060pub async fn receive_ecash_handler<E: Display>(
1061    State(state): State<UiState<DynGatewayApi<E>>>,
1062    _auth: UserAuth,
1063    Form(form): Form<ReceiveEcashForm>,
1064) -> impl IntoResponse {
1065    // Parse the notes string manually to provide better error messages
1066    let notes = match form.notes.trim().parse::<OOBNotes>() {
1067        Ok(n) => n,
1068        Err(e) => {
1069            return Html(
1070                html! {
1071                    div class="alert alert-danger" {
1072                        "Invalid ecash format: " (e)
1073                    }
1074                }
1075                .into_string(),
1076            );
1077        }
1078    };
1079
1080    // Construct payload from parsed notes
1081    let payload = ReceiveEcashPayload {
1082        notes,
1083        wait: form.wait,
1084    };
1085
1086    // Extract federation_id_prefix from notes before consuming payload
1087    let federation_id_prefix = payload.notes.federation_id_prefix();
1088
1089    let markup = match state.api.handle_receive_ecash_msg(payload).await {
1090        Ok(response) => {
1091            // Fetch updated balance for oob swap
1092            let (federation_id, updated_balance) = state
1093                .api
1094                .handle_get_balances_msg()
1095                .await
1096                .ok()
1097                .and_then(|balances| {
1098                    balances
1099                        .ecash_balances
1100                        .into_iter()
1101                        .find(|b| b.federation_id.to_prefix() == federation_id_prefix)
1102                        .map(|b| (b.federation_id, b.ecash_balance_msats))
1103                })
1104                .expect("Federation not found");
1105
1106            let balance_class = if updated_balance == Amount::ZERO {
1107                "alert alert-danger"
1108            } else {
1109                "alert alert-success"
1110            };
1111
1112            let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1113
1114            html! {
1115                div class=(balance_class) {
1116                    div class="d-flex justify-content-between align-items-center" {
1117                        span { "Ecash received successfully!" }
1118                        span class="badge bg-success" { (response.amount) }
1119                    }
1120                }
1121
1122                // Out-of-band swap to update balance banner
1123                div id=(format!("balance-{}", federation_id))
1124                    class=(balance_class)
1125                    data-msats=(updated_balance.msats)
1126                    hx-swap-oob="true"
1127                {
1128                    "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1129                }
1130            }
1131        }
1132        Err(err) => {
1133            html! {
1134                div class="alert alert-danger" {
1135                    "Failed to receive ecash: " (err)
1136                }
1137            }
1138        }
1139    };
1140    Html(markup.into_string())
1141}