Skip to main content

fedimint_gateway_ui/
federation.rs

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