fedimint_gateway_ui/
federation.rs

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