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                                // A flat walletv2 fee has no real fee rate or
867                                // transaction weight to show.
868                                @if !response.flat_peg_out_fee {
869                                    tr {
870                                        td { "Fee Rate" }
871                                        td { (format!("{} sats/kvB", response.peg_out_fees.fee_rate.sats_per_kvb)) }
872                                    }
873                                    tr {
874                                        td { "Transaction Size" }
875                                        td { (format!("{} weight units", response.peg_out_fees.total_weight)) }
876                                    }
877                                }
878                                tr {
879                                    td { "Peg-out Fee" }
880                                    td { (format!("{} sats", response.peg_out_fees.amount().to_sat())) }
881                                }
882                                // Covers every on-federation fee the withdrawal
883                                // incurs, not just the mint's: for walletv2 the
884                                // wallet module's own per-output fee is the
885                                // larger part of it.
886                                @if let Some(federation_fee) = response.mint_fees {
887                                    tr {
888                                        td { "Federation Fee (est.)" }
889                                        td { (format!("~{} msats", federation_fee.msats)) }
890                                    }
891                                }
892                                tr {
893                                    td { strong { "Total Deducted" } }
894                                    td { strong { (format!("{} msats", response.total_cost.msats)) } }
895                                }
896                            }
897                        }
898
899                        div class="d-flex gap-2 mt-3" {
900                            // Confirm form with hidden fields
901                            form hx-post=(WITHDRAW_CONFIRM_ROUTE)
902                                 hx-target=(format!("#withdraw-result-{}", federation_id))
903                                 hx-swap="innerHTML"
904                            {
905                                input type="hidden" name="federation_id" value=(federation_id.to_string());
906                                input type="hidden" name="amount" value=(response.withdraw_amount.sats_round_down().to_string());
907                                input type="hidden" name="address" value=(response.address);
908                                input type="hidden" name="fee_rate_sats_per_kvb" value=(response.peg_out_fees.fee_rate.sats_per_kvb.to_string());
909                                input type="hidden" name="total_weight" value=(response.peg_out_fees.total_weight.to_string());
910
911                                button type="submit" class="btn btn-success" { "Confirm Withdrawal" }
912                            }
913
914                            // Cancel button - clears the result area
915                            button type="button"
916                                   class="btn btn-outline-secondary"
917                                   onclick=(format!("document.getElementById('withdraw-result-{}').innerHTML = ''", federation_id))
918                            { "Cancel" }
919                        }
920                    }
921                }
922            }
923        }
924        Err(err) => {
925            html! {
926                div class="alert alert-danger" {
927                    "Error: " (err.to_string())
928                }
929            }
930        }
931    };
932    Html(markup.into_string())
933}
934
935/// Payload for withdraw confirmation from the UI
936#[derive(Debug, serde::Deserialize)]
937pub struct WithdrawConfirmPayload {
938    pub federation_id: FederationId,
939    pub amount: u64,
940    pub address: String,
941    pub fee_rate_sats_per_kvb: u64,
942    pub total_weight: u64,
943}
944
945/// Confirm handler for two-step withdrawal flow - executes withdrawal with
946/// quoted fees
947pub async fn withdraw_confirm_handler<E: Display>(
948    State(state): State<UiState<DynGatewayApi<E>>>,
949    _auth: UserAuth,
950    Form(payload): Form<WithdrawConfirmPayload>,
951) -> impl IntoResponse {
952    let federation_id = payload.federation_id;
953
954    // Parse the address - it should already be validated from the preview step
955    let address: Address<NetworkUnchecked> = match payload.address.parse() {
956        Ok(addr) => addr,
957        Err(err) => {
958            return Html(
959                html! {
960                    div class="alert alert-danger" {
961                        "Error parsing address: " (err.to_string())
962                    }
963                }
964                .into_string(),
965            );
966        }
967    };
968
969    // Build the WithdrawPayload with the quoted fees
970    let withdraw_payload = WithdrawPayload {
971        federation_id,
972        amount: BitcoinAmountOrAll::Amount(bitcoin::Amount::from_sat(payload.amount)),
973        address,
974        quoted_fees: Some(PegOutFees::new(
975            payload.fee_rate_sats_per_kvb,
976            payload.total_weight,
977        )),
978    };
979
980    let markup = match state.api.handle_withdraw_msg(withdraw_payload).await {
981        Ok(response) => {
982            // Fetch updated balance for the out-of-band swap
983            let updated_balance = state
984                .api
985                .handle_get_balances_msg()
986                .await
987                .ok()
988                .and_then(|balances| {
989                    balances
990                        .ecash_balances
991                        .into_iter()
992                        .find(|b| b.federation_id == federation_id)
993                        .map(|b| b.ecash_balance_msats)
994                })
995                .unwrap_or(Amount::ZERO);
996
997            let balance_class = if updated_balance == Amount::ZERO {
998                "alert alert-danger"
999            } else {
1000                "alert alert-success"
1001            };
1002
1003            let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1004
1005            html! {
1006                // Success message (swaps into result div)
1007                div class="alert alert-success" {
1008                    p { strong { "Withdrawal successful!" } }
1009                    p { "Transaction ID: " code { (response.txid) } }
1010                    p { "Peg-out Fee: " (format!("{} sats", response.fees.amount().to_sat())) }
1011                }
1012
1013                // Out-of-band swap to update balance banner
1014                div id=(format!("balance-{}", federation_id))
1015                    class=(balance_class)
1016                    data-msats=(updated_balance.msats)
1017                    hx-swap-oob="true"
1018                {
1019                    "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1020                }
1021            }
1022        }
1023        Err(err) => {
1024            html! {
1025                div class="alert alert-danger" {
1026                    "Error: " (err.to_string())
1027                }
1028            }
1029        }
1030    };
1031    Html(markup.into_string())
1032}
1033
1034pub async fn spend_ecash_handler<E: Display>(
1035    State(state): State<UiState<DynGatewayApi<E>>>,
1036    _auth: UserAuth,
1037    Form(payload): Form<SpendEcashPayload>,
1038) -> impl IntoResponse {
1039    let federation_id = payload.federation_id;
1040    let requested_amount = payload.amount;
1041
1042    let markup = match state.api.handle_spend_ecash_msg(payload).await {
1043        Ok(response) => {
1044            let notes_string = response.notes.clone();
1045
1046            // Compute actual amount from notes string - try ECash first, then OOBNotes
1047            let actual_amount = if let Ok(ecash) = base32::decode_prefixed::<
1048                fedimint_mintv2_client::ECash,
1049            >(FEDIMINT_PREFIX, &notes_string)
1050            {
1051                ecash.amount()
1052            } else if let Ok(notes) = notes_string.parse::<OOBNotes>() {
1053                notes.total_amount()
1054            } else {
1055                return Html(
1056                    html! {
1057                        div class="alert alert-danger" {
1058                            "Failed to parse returned ecash notes"
1059                        }
1060                    }
1061                    .into_string(),
1062                );
1063            };
1064            let overspent = actual_amount > requested_amount;
1065
1066            // Fetch updated balance for the out-of-band swap
1067            let updated_balance = state
1068                .api
1069                .handle_get_balances_msg()
1070                .await
1071                .ok()
1072                .and_then(|balances| {
1073                    balances
1074                        .ecash_balances
1075                        .into_iter()
1076                        .find(|b| b.federation_id == federation_id)
1077                        .map(|b| b.ecash_balance_msats)
1078                })
1079                .unwrap_or(Amount::ZERO);
1080
1081            let balance_class = if updated_balance == Amount::ZERO {
1082                "alert alert-danger"
1083            } else {
1084                "alert alert-success"
1085            };
1086
1087            let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1088
1089            html! {
1090                div class="card card-body bg-light" {
1091                    div class="d-flex justify-content-between align-items-center mb-2" {
1092                        span class="fw-bold" { "Ecash Generated" }
1093                        span class="badge bg-success" { (actual_amount) }
1094                    }
1095
1096                    @if overspent {
1097                        div class="alert alert-warning py-2 mb-2" {
1098                            "Note: Spent " (actual_amount) " ("
1099                            (actual_amount.saturating_sub(requested_amount))
1100                            " more than requested due to note denominations)"
1101                        }
1102                    }
1103
1104                    div class="mb-2" {
1105                        label class="form-label small text-muted" { "Ecash Notes (click to copy):" }
1106                        textarea
1107                            class="form-control font-monospace"
1108                            rows="4"
1109                            readonly
1110                            onclick="copyToClipboard(this)"
1111                            style="font-size: 0.85rem;"
1112                        { (notes_string) }
1113                        small class="text-muted" { "Click to copy" }
1114                    }
1115                }
1116
1117                // Out-of-band swap to update balance banner
1118                div id=(format!("balance-{}", federation_id))
1119                    class=(balance_class)
1120                    data-msats=(updated_balance.msats)
1121                    hx-swap-oob="true"
1122                {
1123                    "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1124                }
1125            }
1126        }
1127        Err(err) => {
1128            html! {
1129                div class="alert alert-danger" {
1130                    "Failed to generate ecash: " (err)
1131                }
1132            }
1133        }
1134    };
1135    Html(markup.into_string())
1136}
1137
1138pub async fn receive_ecash_handler<E: Display>(
1139    State(state): State<UiState<DynGatewayApi<E>>>,
1140    _auth: UserAuth,
1141    Form(form): Form<ReceiveEcashForm>,
1142) -> impl IntoResponse {
1143    let notes_str = form.notes.trim().to_string();
1144
1145    // Try to extract federation_id_prefix - try ECash first, then OOBNotes
1146    let federation_id_prefix = if let Ok(ecash) =
1147        base32::decode_prefixed::<fedimint_mintv2_client::ECash>(FEDIMINT_PREFIX, &notes_str)
1148    {
1149        match ecash.mint() {
1150            Some(fed_id) => fed_id.to_prefix(),
1151            None => {
1152                return Html(
1153                    html! {
1154                        div class="alert alert-danger" {
1155                            "Invalid ecash format: missing federation ID"
1156                        }
1157                    }
1158                    .into_string(),
1159                );
1160            }
1161        }
1162    } else if let Ok(notes) = notes_str.parse::<OOBNotes>() {
1163        notes.federation_id_prefix()
1164    } else {
1165        return Html(
1166            html! {
1167                div class="alert alert-danger" {
1168                    "Invalid ecash format: could not parse as ECash or OOBNotes"
1169                }
1170            }
1171            .into_string(),
1172        );
1173    };
1174
1175    // Construct payload with string notes
1176    let payload = ReceiveEcashPayload { notes: notes_str };
1177
1178    let markup = match state.api.handle_receive_ecash_msg(payload).await {
1179        Ok(response) => {
1180            // Fetch updated balance for oob swap
1181            let (federation_id, updated_balance) = state
1182                .api
1183                .handle_get_balances_msg()
1184                .await
1185                .ok()
1186                .and_then(|balances| {
1187                    balances
1188                        .ecash_balances
1189                        .into_iter()
1190                        .find(|b| b.federation_id.to_prefix() == federation_id_prefix)
1191                        .map(|b| (b.federation_id, b.ecash_balance_msats))
1192                })
1193                .expect("Federation not found");
1194
1195            let balance_class = if updated_balance == Amount::ZERO {
1196                "alert alert-danger"
1197            } else {
1198                "alert alert-success"
1199            };
1200
1201            let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1202
1203            html! {
1204                div class="alert alert-success" {
1205                    div class="d-flex justify-content-between align-items-center" {
1206                        span { "Ecash received successfully!" }
1207                        span class="badge bg-success" { (response.amount) }
1208                    }
1209                }
1210
1211                // Out-of-band swap to update balance banner
1212                div id=(format!("balance-{}", federation_id))
1213                    class=(balance_class)
1214                    data-msats=(updated_balance.msats)
1215                    hx-swap-oob="true"
1216                {
1217                    "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1218                }
1219            }
1220        }
1221        Err(err) => {
1222            html! {
1223                div class="alert alert-danger" {
1224                    "Failed to receive ecash: " (err)
1225                }
1226            }
1227        }
1228    };
1229    Html(markup.into_string())
1230}