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