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