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