fedimint_gateway_ui/
lightning.rs

1use std::collections::HashMap;
2use std::fmt::Display;
3use std::time::{Duration, UNIX_EPOCH};
4
5use axum::Form;
6use axum::extract::{Query, State};
7use axum::response::Html;
8use chrono::offset::LocalResult;
9use chrono::{TimeZone, Utc};
10use fedimint_core::bitcoin::Network;
11use fedimint_core::time::now;
12use fedimint_gateway_common::{
13    ChannelInfo, CloseChannelsWithPeerRequest, CreateInvoiceForOperatorPayload, GatewayBalances,
14    GatewayInfo, LightningInfo, LightningMode, ListTransactionsPayload, ListTransactionsResponse,
15    OpenChannelRequest, PayInvoiceForOperatorPayload, PaymentStatus, SendOnchainRequest,
16};
17use fedimint_logging::LOG_GATEWAY_UI;
18use fedimint_ui_common::UiState;
19use fedimint_ui_common::auth::UserAuth;
20use maud::{Markup, PreEscaped, html};
21use qrcode::QrCode;
22use qrcode::render::svg;
23use tracing::debug;
24
25use crate::{
26    CHANNEL_FRAGMENT_ROUTE, CLOSE_CHANNEL_ROUTE, CREATE_BOLT11_INVOICE_ROUTE, DynGatewayApi,
27    LN_ONCHAIN_ADDRESS_ROUTE, OPEN_CHANNEL_ROUTE, PAY_BOLT11_INVOICE_ROUTE,
28    PAYMENTS_FRAGMENT_ROUTE, SEND_ONCHAIN_ROUTE, TRANSACTIONS_FRAGMENT_ROUTE,
29    WALLET_FRAGMENT_ROUTE,
30};
31
32pub async fn render<E>(gateway_info: &GatewayInfo, api: &DynGatewayApi<E>) -> Markup
33where
34    E: std::fmt::Display,
35{
36    debug!(target: LOG_GATEWAY_UI, "Listing lightning channels...");
37    // Try to load channels
38    let channels_result = api.handle_list_channels_msg().await;
39
40    let (block_height, status_badge, network, alias, pubkey) =
41        if gateway_info.gateway_state == "Syncing" {
42            (
43                0,
44                html! { span class="badge bg-warning" { "🟡 Syncing" } },
45                Network::Bitcoin,
46                None,
47                None,
48            )
49        } else {
50            match &gateway_info.lightning_info {
51                LightningInfo::Connected {
52                    network,
53                    block_height,
54                    synced_to_chain,
55                    alias,
56                    public_key,
57                } => {
58                    let badge = if *synced_to_chain {
59                        html! { span class="badge bg-success" { "🟢 Synced" } }
60                    } else {
61                        html! { span class="badge bg-warning" { "🟡 Syncing" } }
62                    };
63                    (
64                        *block_height,
65                        badge,
66                        *network,
67                        Some(alias.clone()),
68                        Some(*public_key),
69                    )
70                }
71                LightningInfo::NotConnected => (
72                    0,
73                    html! { span class="badge bg-danger" { "❌ Not Connected" } },
74                    Network::Bitcoin,
75                    None,
76                    None,
77                ),
78            }
79        };
80
81    let is_lnd = matches!(api.lightning_mode(), LightningMode::Lnd { .. });
82    debug!(target: LOG_GATEWAY_UI, "Getting all balances...");
83    let balances_result = api.handle_get_balances_msg().await;
84    let now = now();
85    let start = now
86        .checked_sub(Duration::from_secs(60 * 60 * 24))
87        .expect("Cannot be negative");
88    let start_secs = start
89        .duration_since(UNIX_EPOCH)
90        .expect("Cannot be before epoch")
91        .as_secs();
92    let end = now;
93    let end_secs = end
94        .duration_since(UNIX_EPOCH)
95        .expect("Cannot be before epoch")
96        .as_secs();
97    debug!(target: LOG_GATEWAY_UI, "Listing lightning transactions...");
98    let transactions_result = api
99        .handle_list_transactions_msg(ListTransactionsPayload {
100            start_secs,
101            end_secs,
102        })
103        .await;
104
105    html! {
106        script {
107            (PreEscaped(r#"
108            function copyToClipboard(input) {
109                input.select();
110                document.execCommand('copy');
111                const hint = input.nextElementSibling;
112                hint.textContent = 'Copied!';
113                setTimeout(() => hint.textContent = 'Click to copy', 2000);
114            }
115            "#))
116        }
117
118        div class="card h-100" {
119            div class="card-header dashboard-header" { "Lightning Node" }
120            div class="card-body" {
121
122                // --- TABS ---
123                ul class="nav nav-tabs" id="lightningTabs" role="tablist" {
124                    li class="nav-item" role="presentation" {
125                        button class="nav-link active"
126                            id="connection-tab"
127                            data-bs-toggle="tab"
128                            data-bs-target="#connection-tab-pane"
129                            type="button"
130                            role="tab"
131                        { "Connection Info" }
132                    }
133                    li class="nav-item" role="presentation" {
134                        button class="nav-link"
135                            id="wallet-tab"
136                            data-bs-toggle="tab"
137                            data-bs-target="#wallet-tab-pane"
138                            type="button"
139                            role="tab"
140                        { "Wallet" }
141                    }
142                    li class="nav-item" role="presentation" {
143                        button class="nav-link"
144                            id="channels-tab"
145                            data-bs-toggle="tab"
146                            data-bs-target="#channels-tab-pane"
147                            type="button"
148                            role="tab"
149                        { "Channels" }
150                    }
151                    li class="nav-item" role="presentation" {
152                        button class="nav-link"
153                            id="payments-tab"
154                            data-bs-toggle="tab"
155                            data-bs-target="#payments-tab-pane"
156                            type="button"
157                            role="tab"
158                        { "Payments" }
159                    }
160                    li class="nav-item" role="presentation" {
161                        button class="nav-link"
162                            id="transactions-tab"
163                            data-bs-toggle="tab"
164                            data-bs-target="#transactions-tab-pane"
165                            type="button"
166                            role="tab"
167                        { "Transactions" }
168                    }
169                }
170
171                div class="tab-content mt-3" id="lightningTabsContent" {
172
173                    // ──────────────────────────────────────────
174                    //   TAB: CONNECTION INFO
175                    // ──────────────────────────────────────────
176                    div class="tab-pane fade show active"
177                        id="connection-tab-pane"
178                        role="tabpanel"
179                        aria-labelledby="connection-tab" {
180
181                        @match &gateway_info.lightning_mode {
182                            LightningMode::Lnd { lnd_rpc_addr, lnd_tls_cert, lnd_macaroon } => {
183                                div id="node-type" class="alert alert-info" {
184                                    "Node Type: " strong { "External LND" }
185                                }
186                                table class="table table-sm mb-0" {
187                                    tbody {
188                                        tr {
189                                            th { "RPC Address" }
190                                            td { (lnd_rpc_addr) }
191                                        }
192                                        tr {
193                                            th { "TLS Cert" }
194                                            td { (lnd_tls_cert) }
195                                        }
196                                        tr {
197                                            th { "Macaroon" }
198                                            td { (lnd_macaroon) }
199                                        }
200                                        tr {
201                                            th { "Network" }
202                                            td { (network) }
203                                        }
204                                        tr {
205                                            th { "Block Height" }
206                                            td { (block_height) }
207                                        }
208                                        tr {
209                                            th { "Status" }
210                                            td { (status_badge) }
211                                        }
212                                        @if let Some(a) = alias {
213                                            tr {
214                                                th { "Alias" }
215                                                td { (a) }
216                                            }
217                                        }
218                                        @if let Some(pk) = pubkey {
219                                            tr {
220                                                th { "Public Key" }
221                                                td { (pk) }
222                                            }
223                                        }
224                                    }
225                                }
226                            }
227                            LightningMode::Ldk { lightning_port, .. } => {
228                                div id="node-type" class="alert alert-info" {
229                                    "Node Type: " strong { "Internal LDK" }
230                                }
231                                table class="table table-sm mb-0" {
232                                    tbody {
233                                        tr {
234                                            th { "Port" }
235                                            td { (lightning_port) }
236                                        }
237                                        tr {
238                                            th { "Network" }
239                                            td { (network) }
240                                        }
241                                        tr {
242                                            th { "Block Height" }
243                                            td { (block_height) }
244                                        }
245                                        tr {
246                                            th { "Status" }
247                                            td { (status_badge) }
248                                        }
249                                        @if let Some(a) = alias {
250                                            tr {
251                                                th { "Alias" }
252                                                td { (a) }
253                                            }
254                                        }
255                                        @if let Some(pk) = pubkey {
256                                            tr {
257                                                th { "Public Key" }
258                                                td { (pk) }
259                                            }
260                                        }
261                                    }
262                                }
263                            }
264                        }
265                    }
266
267                    // ──────────────────────────────────────────
268                    //   TAB: WALLET
269                    // ──────────────────────────────────────────
270                    div class="tab-pane fade"
271                        id="wallet-tab-pane"
272                        role="tabpanel"
273                        aria-labelledby="wallet-tab" {
274
275                        div class="d-flex justify-content-between align-items-center mb-2" {
276                            div { strong { "Wallet" } }
277                            button class="btn btn-sm btn-outline-secondary"
278                                hx-get=(WALLET_FRAGMENT_ROUTE)
279                                hx-target="#wallet-container"
280                                hx-swap="outerHTML"
281                                type="button"
282                            { "Refresh" }
283                        }
284
285                        (wallet_fragment_markup(&balances_result, None, None))
286                    }
287
288                    // ──────────────────────────────────────────
289                    //   TAB: CHANNELS
290                    // ──────────────────────────────────────────
291                    div class="tab-pane fade"
292                        id="channels-tab-pane"
293                        role="tabpanel"
294                        aria-labelledby="channels-tab" {
295
296                        div class="d-flex justify-content-between align-items-center mb-2" {
297                            div { strong { "Channels" } }
298                            button class="btn btn-sm btn-outline-secondary"
299                                hx-get=(CHANNEL_FRAGMENT_ROUTE)
300                                hx-target="#channels-container"
301                                hx-swap="outerHTML"
302                                type="button"
303                            { "Refresh" }
304                        }
305
306                        (channels_fragment_markup(channels_result, None, None, is_lnd))
307                    }
308
309                    // ──────────────────────────────────────────
310                    //   TAB: PAYMENTS
311                    // ──────────────────────────────────────────
312                    div class="tab-pane fade"
313                        id="payments-tab-pane"
314                        role="tabpanel"
315                        aria-labelledby="payments-tab" {
316
317                        div class="d-flex justify-content-between align-items-center mb-2" {
318                            div { strong { "Payments" } }
319                            button class="btn btn-sm btn-outline-secondary"
320                                hx-get=(PAYMENTS_FRAGMENT_ROUTE)
321                                hx-target="#payments-container"
322                                hx-swap="outerHTML"
323                                type="button"
324                            { "Refresh" }
325                        }
326
327                        (payments_fragment_markup(&balances_result, None, None, None))
328                    }
329
330                    // ──────────────────────────────────────────
331                    //   TAB: TRANSACTIONS
332                    // ──────────────────────────────────────────
333                    div class="tab-pane fade"
334                        id="transactions-tab-pane"
335                        role="tabpanel"
336                        aria-labelledby="transactions-tab" {
337
338                        (transactions_fragment_markup(&transactions_result, start_secs, end_secs))
339                    }
340                }
341            }
342        }
343    }
344}
345
346pub fn transactions_fragment_markup<E>(
347    transactions_result: &Result<ListTransactionsResponse, E>,
348    start_secs: u64,
349    end_secs: u64,
350) -> Markup
351where
352    E: std::fmt::Display,
353{
354    // Convert timestamps to datetime-local formatted strings
355    let start_dt = match Utc.timestamp_opt(start_secs as i64, 0) {
356        LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
357        _ => "1970-01-01T00:00:00".to_string(),
358    };
359
360    let end_dt = match Utc.timestamp_opt(end_secs as i64, 0) {
361        LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
362        _ => "1970-01-01T00:00:00".to_string(),
363    };
364
365    html!(
366        div id="transactions-container" {
367
368            // ────────────────────────────────
369            //   Date Range Form
370            // ────────────────────────────────
371            form class="row g-3 mb-3"
372                hx-get=(TRANSACTIONS_FRAGMENT_ROUTE)
373                hx-target="#transactions-container"
374                hx-swap="outerHTML"
375            {
376                // Start
377                div class="col-auto" {
378                    label class="form-label" for="start-secs" { "Start" }
379                    input
380                        class="form-control"
381                        type="datetime-local"
382                        id="start-secs"
383                        name="start_secs"
384                        step="1"
385                        value=(start_dt);
386                }
387
388                // End
389                div class="col-auto" {
390                    label class="form-label" for="end-secs" { "End" }
391                    input
392                        class="form-control"
393                        type="datetime-local"
394                        id="end-secs"
395                        name="end_secs"
396                        step="1"
397                        value=(end_dt);
398                }
399
400                // Refresh Button
401                div class="col-auto align-self-end" {
402                    button class="btn btn-outline-secondary" type="submit" { "Refresh" }
403                    button class="btn btn-outline-secondary me-2" type="button"
404                        id="last-day-btn"
405                    { "Last Day" }
406                }
407            }
408
409            script {
410                (PreEscaped(r#"
411                document.getElementById('last-day-btn').addEventListener('click', () => {
412                    const now = new Date();
413                    const endInput = document.getElementById('end-secs');
414                    const startInput = document.getElementById('start-secs');
415
416                    const pad = n => n.toString().padStart(2, '0');
417
418                    const formatUTC = d =>
419                        `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
420
421                    endInput.value = formatUTC(now);
422
423                    const start = new Date(now.getTime() - 24*60*60*1000); // 24 hours ago UTC
424                    startInput.value = formatUTC(start);
425                });
426                "#))
427            }
428
429            // ────────────────────────────────
430            //   Transaction List
431            // ────────────────────────────────
432            @match transactions_result {
433                Err(err) => {
434                    div class="alert alert-danger" {
435                        "Failed to load lightning transactions: " (err)
436                    }
437                }
438                Ok(transactions) => {
439                    @if transactions.transactions.is_empty() {
440                        div class="alert alert-info mt-3" {
441                            "No transactions found in this time range."
442                        }
443                    } @else {
444                        ul class="list-group mt-3" {
445                            @for tx in &transactions.transactions {
446                                li class="list-group-item p-2 mb-1 transaction-item"
447                                    style="border-radius: 0.5rem; transition: background-color 0.2s;"
448                                {
449                                    // Hover effect using inline style (or add a CSS class)
450                                    div style="display: flex; justify-content: space-between; align-items: center;" {
451                                        // Left: kind + direction + status
452                                        div {
453                                            div style="font-weight: bold; font-size: 0.9rem;" {
454                                                (format!("{:?}", tx.payment_kind))
455                                                " — "
456                                                span { (format!("{:?}", tx.direction)) }
457                                            }
458
459                                            div style="font-size: 0.75rem; margin-top: 2px;" {
460                                                @let status_badge = match tx.status {
461                                                    PaymentStatus::Pending => html! { span class="badge bg-warning" { "⏳ Pending" } },
462                                                    PaymentStatus::Succeeded => html! { span class="badge bg-success" { "✅ Succeeded" } },
463                                                    PaymentStatus::Failed => html! { span class="badge bg-danger" { "❌ Failed" } },
464                                                };
465                                                (status_badge)
466                                            }
467                                        }
468
469                                        // Right: amount + timestamp
470                                        div style="text-align: right;" {
471                                            div style="font-weight: bold; font-size: 0.9rem;" {
472                                                (format!("{} sats", tx.amount.msats / 1000))
473                                            }
474                                            div style="font-size: 0.7rem; color: #6c757d;" {
475                                                @let timestamp = match Utc.timestamp_opt(tx.timestamp_secs as i64, 0) {
476                                                    LocalResult::Single(dt) => dt,
477                                                    _ => Utc.timestamp_opt(0, 0).unwrap(),
478                                                };
479                                                (timestamp.format("%Y-%m-%d %H:%M:%S").to_string())
480                                            }
481                                        }
482                                    }
483
484                                    // Optional hash/preimage, bottom row
485                                    @if let Some(hash) = &tx.payment_hash {
486                                        div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 2px;" {
487                                            "Hash: " (hash.to_string())
488                                        }
489                                    }
490
491                                    @if let Some(preimage) = &tx.preimage {
492                                        div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 1px;" {
493                                            "Preimage: " (preimage)
494                                        }
495                                    }
496
497                                    // Hover effect using inline JS (or move to CSS)
498                                    script {
499                                        (PreEscaped(r#"
500                                        const li = document.currentScript.parentElement;
501                                        li.addEventListener('mouseenter', () => li.style.backgroundColor = '#f8f9fa');
502                                        li.addEventListener('mouseleave', () => li.style.backgroundColor = 'white');
503                                        "#))
504                                    }
505                                }
506                            }
507                        }
508                    }
509                }
510            }
511        }
512    )
513}
514
515pub fn payments_fragment_markup<E>(
516    balances_result: &Result<GatewayBalances, E>,
517    created_invoice: Option<String>,
518    success_msg: Option<String>,
519    error_msg: Option<String>,
520) -> Markup
521where
522    E: std::fmt::Display,
523{
524    html!(
525        div id="payments-container" {
526            @match balances_result {
527                Err(err) => {
528                    // Error banner — no buttons below
529                    div class="alert alert-danger" {
530                        "Failed to load lightning balance: " (err)
531                    }
532                }
533                Ok(bal) => {
534
535                    @if let Some(success) = success_msg {
536                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
537                            span { (success) }
538                        }
539                    }
540
541                    @if let Some(error) = error_msg {
542                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
543                            span { (error) }
544                        }
545                    }
546
547                    div id="lightning-balance-banner"
548                        class="alert alert-info d-flex justify-content-between align-items-center" {
549
550                        @let lightning_balance = format!("{}", fedimint_core::Amount::from_msats(bal.lightning_balance_msats));
551
552                        span {
553                            "Lightning Balance: "
554                            strong id="lightning-balance" { (lightning_balance) }
555                        }
556                    }
557
558                    // Buttons
559                    div class="mt-3" {
560                        button class="btn btn-sm btn-outline-primary me-2"
561                            type="button"
562                            onclick="
563                                document.getElementById('receive-form').classList.add('d-none');
564                                document.getElementById('pay-invoice-form').classList.toggle('d-none');
565                            "
566                        { "Send" }
567
568                        button class="btn btn-sm btn-outline-success"
569                            type="button"
570                            onclick="
571                                document.getElementById('pay-invoice-form').classList.add('d-none');
572                                document.getElementById('receive-form').classList.toggle('d-none');
573                            "
574                        { "Receive" }
575                    }
576
577                    // Send form
578                    div id="pay-invoice-form" class="card card-body mt-3 d-none" {
579                        form
580                            id="pay-ln-invoice-form"
581                            hx-post=(PAY_BOLT11_INVOICE_ROUTE)
582                            hx-target="#payments-container"
583                            hw-swap="outerHTML"
584                        {
585                            div class="mb-3" {
586                                label class="form-label" for="invoice" { "Bolt11 Invoice" }
587                                input type="text"
588                                    class="form-control"
589                                    id="invoice"
590                                    name="invoice"
591                                    required;
592                            }
593
594                            button
595                                type="submit"
596                                class="btn btn-success btn-sm"
597                            { "Pay Invoice" }
598                        }
599                    }
600
601                    // Receive form
602                    div id="receive-form" class={
603                        @if created_invoice.is_some() { "card card-body mt-3 d-none" }
604                        @else { "card card-body mt-3 d-none" }
605                    } {
606                        form
607                            id="create-ln-invoice-form"
608                            hx-post=(CREATE_BOLT11_INVOICE_ROUTE)
609                            hx-target="#payments-container"
610                            hx-swap="outerHTML"
611                        {
612                            div class="mb-3" {
613                                label class="form-label" for="amount_msats" { "Amount (msats)" }
614                                input type="number"
615                                    class="form-control"
616                                    id="amount_msats"
617                                    name="amount_msats"
618                                    min="1"
619                                    required;
620                            }
621
622                            button
623                                type="submit"
624                                class="btn btn-success btn-sm"
625                            { "Create Bolt11 Invoice" }
626                        }
627                    }
628
629                    // ──────────────────────────────────────────
630                    //  SHOW CREATED INVOICE
631                    // ──────────────────────────────────────────
632                    @if let Some(invoice) = created_invoice {
633
634                        @let code =
635                            QrCode::new(&invoice).expect("Failed to generate QR code");
636                        @let qr_svg = code.render::<svg::Color>().build();
637
638                        div class="card card-body mt-4" {
639
640                            div class="card card-body bg-light d-flex flex-column align-items-center" {
641                                span class="fw-bold mb-3" { "Bolt11 Invoice:" }
642
643                                // Flex container: address on left, QR on right
644                                div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
645
646                                    // Copyable input + text
647                                    div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
648                                        input type="text"
649                                            readonly
650                                            class="form-control mb-2"
651                                            style="text-align:left; font-family: monospace; font-size:1rem;"
652                                            value=(invoice)
653                                            onclick="copyToClipboard(this)"
654                                        {}
655                                        small class="text-muted" { "Click to copy" }
656                                    }
657
658                                    // QR code
659                                    div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
660                                        style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
661                                    {
662                                        (PreEscaped(format!(
663                                            r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
664                                            qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
665                                        )))
666                                    }
667                                }
668                            }
669                        }
670                    }
671                }
672            }
673        }
674    )
675}
676
677pub fn wallet_fragment_markup<E>(
678    balances_result: &Result<GatewayBalances, E>,
679    success_msg: Option<String>,
680    error_msg: Option<String>,
681) -> Markup
682where
683    E: std::fmt::Display,
684{
685    html!(
686        div id="wallet-container" {
687            @match balances_result {
688                Err(err) => {
689                    // Error banner — no buttons below
690                    div class="alert alert-danger" {
691                        "Failed to load wallet balance: " (err)
692                    }
693                }
694                Ok(bal) => {
695
696                    @if let Some(success) = success_msg {
697                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
698                            span { (success) }
699                        }
700                    }
701
702                    @if let Some(error) = error_msg {
703                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
704                            span { (error) }
705                        }
706                    }
707
708                    div id="wallet-balance-banner"
709                        class="alert alert-info d-flex justify-content-between align-items-center" {
710
711                        @let onchain = format!("{}", bitcoin::Amount::from_sat(bal.onchain_balance_sats));
712
713                        span {
714                            "Balance: "
715                            strong id="wallet-balance" { (onchain) }
716                        }
717                    }
718
719                    div class="mt-3" {
720                        // Toggle Send Form button
721                        button class="btn btn-sm btn-outline-primary me-2"
722                            type="button"
723                            onclick="
724                                document.getElementById('send-form').classList.toggle('d-none');
725                                document.getElementById('receive-address-container').innerHTML = '';
726                            "
727                        { "Send" }
728
729
730                        button class="btn btn-sm btn-outline-success"
731                            hx-get=(LN_ONCHAIN_ADDRESS_ROUTE)
732                            hx-target="#receive-address-container"
733                            hx-swap="outerHTML"
734                            type="button"
735                            onclick="document.getElementById('send-form').classList.add('d-none');"
736                        { "Receive" }
737                    }
738
739                    // ──────────────────────────────────────────
740                    //   Send Form (hidden until toggled)
741                    // ──────────────────────────────────────────
742                    div id="send-form" class="card card-body mt-3 d-none" {
743
744                        form
745                            id="send-onchain-form"
746                            hx-post=(SEND_ONCHAIN_ROUTE)
747                            hx-target="#wallet-container"
748                            hx-swap="outerHTML"
749                        {
750                            // Address
751                            div class="mb-3" {
752                                label class="form-label" for="address" { "Bitcoin Address" }
753                                input
754                                    type="text"
755                                    class="form-control"
756                                    id="address"
757                                    name="address"
758                                    required;
759                            }
760
761                            // Amount + ALL button
762                            div class="mb-3" {
763                                label class="form-label" for="amount" { "Amount (sats)" }
764                                div class="input-group" {
765                                    input
766                                        type="text"
767                                        class="form-control"
768                                        id="amount"
769                                        name="amount"
770                                        placeholder="e.g. 10000 or all"
771                                        required;
772
773                                    button
774                                        class="btn btn-outline-secondary"
775                                        type="button"
776                                        onclick="document.getElementById('amount').value = 'all';"
777                                    { "All" }
778                                }
779                            }
780
781                            // Fee Rate
782                            div class="mb-3" {
783                                label class="form-label" for="fee_rate" { "Sats per vbyte" }
784                                input
785                                    type="number"
786                                    class="form-control"
787                                    id="fee_rate"
788                                    name="fee_rate_sats_per_vbyte"
789                                    min="1"
790                                    required;
791                            }
792
793                            // Confirm Send
794                            div class="mt-3" {
795                                button
796                                    type="submit"
797                                    class="btn btn-sm btn-primary"
798                                {
799                                    "Confirm Send"
800                                }
801                            }
802                        }
803                    }
804
805                    div id="receive-address-container" class="mt-3" {}
806                }
807            }
808        }
809    )
810}
811
812// channels_fragment_markup converts either the channels Vec or an error string
813// into a chunk of HTML (the thing HTMX will replace).
814pub fn channels_fragment_markup<E>(
815    channels_result: Result<Vec<ChannelInfo>, E>,
816    success_msg: Option<String>,
817    error_msg: Option<String>,
818    is_lnd: bool,
819) -> Markup
820where
821    E: std::fmt::Display,
822{
823    html! {
824        // This outer div is what we'll replace with hx-swap="outerHTML"
825        div id="channels-container" {
826            @match channels_result {
827                Err(err_str) => {
828                    div class="alert alert-danger" {
829                        "Failed to load channels: " (err_str)
830                    }
831                }
832                Ok(channels) => {
833
834                    @if let Some(success) = success_msg {
835                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
836                            span { (success) }
837                        }
838                    }
839
840                    @if let Some(error) = error_msg {
841                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
842                            span { (error) }
843                        }
844                    }
845
846                    @if channels.is_empty() {
847                        div class="alert alert-info" { "No channels found." }
848                    } @else {
849                        table class="table table-sm align-middle" {
850                            thead {
851                                tr {
852                                    th { "Remote PubKey" }
853                                    th { "Alias" }
854                                    th { "Funding OutPoint" }
855                                    th { "Size (sats)" }
856                                    th { "Active" }
857                                    th { "Liquidity" }
858                                    th { "" }
859                                }
860                            }
861                            tbody {
862                                @for ch in channels {
863                                    @let row_id = format!("close-form-{}", ch.remote_pubkey);
864                                    // precompute safely (no @let inline arithmetic)
865                                    @let size = ch.channel_size_sats.max(1);
866                                    @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
867                                    @let inbound_pct  = (ch.inbound_liquidity_sats  as f64 / size as f64) * 100.0;
868                                    @let funding_outpoint = if let Some(funding_outpoint) = ch.funding_outpoint {
869                                        funding_outpoint.to_string()
870                                    } else {
871                                        "".to_string()
872                                    };
873
874                                    tr {
875                                        td { (ch.remote_pubkey.to_string()) }
876                                        td {
877                                            @if let Some(alias) = &ch.remote_node_alias {
878                                                (alias)
879                                            } @else {
880                                                span class="text-muted" { "-" }
881                                            }
882                                        }
883                                        td { (funding_outpoint) }
884                                        td { (ch.channel_size_sats) }
885                                        td {
886                                            @if ch.is_active {
887                                                span class="badge bg-success" { "active" }
888                                            } @else {
889                                                span class="badge bg-secondary" { "inactive" }
890                                            }
891                                        }
892
893                                        // Liquidity bar: single horizontal bar split by two divs
894                                        td {
895                                            div style="width:240px;" {
896                                                div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
897                                                    div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
898                                                    div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
899                                                }
900
901                                                div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
902                                                    span {
903                                                        span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
904                                                        (format!("Outbound ({})", ch.outbound_liquidity_sats))
905                                                    }
906                                                    span {
907                                                        span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
908                                                        (format!("Inbound ({})", ch.inbound_liquidity_sats))
909                                                    }
910                                                }
911                                            }
912                                        }
913
914                                        td style="width: 70px" {
915                                            // X button toggles a per-row collapse
916                                            button class="btn btn-sm btn-outline-danger"
917                                                type="button"
918                                                data-bs-toggle="collapse"
919                                                data-bs-target=(format!("#{row_id}"))
920                                                aria-expanded="false"
921                                                aria-controls=(row_id)
922                                            { "X" }
923                                        }
924                                    }
925
926                                    tr class="collapse" id=(row_id) {
927                                        td colspan="7" {
928                                            div class="card card-body" {
929                                                form
930                                                    hx-post=(CLOSE_CHANNEL_ROUTE)
931                                                    hx-target="#channels-container"
932                                                    hx-swap="outerHTML"
933                                                    hx-indicator=(format!("#close-spinner-{}", ch.remote_pubkey))
934                                                    hx-disabled-elt="button[type='submit']"
935                                                {
936                                                    // always required
937                                                    input type="hidden"
938                                                        name="pubkey"
939                                                        value=(ch.remote_pubkey.to_string()) {}
940
941                                                    div class="form-check mb-3" {
942                                                        input class="form-check-input"
943                                                            type="checkbox"
944                                                            name="force"
945                                                            value="true"
946                                                            id=(format!("force-{}", ch.remote_pubkey))
947                                                            onchange=(format!(
948                                                                "const input = document.getElementById('sats-vb-{}'); \
949                                                                input.disabled = this.checked;",
950                                                                ch.remote_pubkey
951                                                            )) {}
952                                                        label class="form-check-label"
953                                                            for=(format!("force-{}", ch.remote_pubkey)) {
954                                                            "Force Close"
955                                                        }
956                                                    }
957
958                                                    // -------------------------------------------
959                                                    // CONDITIONAL sats/vbyte input
960                                                    // -------------------------------------------
961                                                    @if is_lnd {
962                                                        div class="mb-3" id=(format!("sats-vb-div-{}", ch.remote_pubkey)) {
963                                                            label class="form-label" for=(format!("sats-vb-{}", ch.remote_pubkey)) {
964                                                                "Sats per vbyte"
965                                                            }
966                                                            input
967                                                                type="number"
968                                                                min="1"
969                                                                step="1"
970                                                                class="form-control"
971                                                                id=(format!("sats-vb-{}", ch.remote_pubkey))
972                                                                name="sats_per_vbyte"
973                                                                required
974                                                                placeholder="Enter fee rate" {}
975
976                                                            small class="text-muted" {
977                                                                "Required for LND fee estimation"
978                                                            }
979                                                        }
980                                                    } @else {
981                                                        // LDK → auto-filled, hidden
982                                                        input type="hidden"
983                                                            name="sats_per_vbyte"
984                                                            value="1" {}
985                                                    }
986
987                                                    // spinner for this specific channel
988                                                    div class="htmx-indicator mt-2"
989                                                        id=(format!("close-spinner-{}", ch.remote_pubkey)) {
990                                                        div class="spinner-border spinner-border-sm text-danger" role="status" {}
991                                                        span { " Closing..." }
992                                                    }
993
994                                                    button type="submit"
995                                                        class="btn btn-danger btn-sm" {
996                                                        "Confirm Close"
997                                                    }
998                                                }
999                                            }
1000                                        }
1001                                    }
1002                                }
1003                            }
1004                        }
1005                    }
1006
1007                    div class="mt-3" {
1008                        // Toggle button
1009                        button id="open-channel-btn" class="btn btn-sm btn-primary"
1010                            type="button"
1011                            data-bs-toggle="collapse"
1012                            data-bs-target="#open-channel-form"
1013                            aria-expanded="false"
1014                            aria-controls="open-channel-form"
1015                        { "Open Channel" }
1016
1017                        // Collapsible form
1018                        div id="open-channel-form" class="collapse mt-3" {
1019                            form hx-post=(OPEN_CHANNEL_ROUTE)
1020                                hx-target="#channels-container"
1021                                hx-swap="outerHTML"
1022                                class="card card-body" {
1023
1024                                h5 class="card-title" { "Open New Channel" }
1025
1026                                div class="mb-2" {
1027                                    label class="form-label" { "Remote Node Public Key" }
1028                                    input type="text" name="pubkey" class="form-control" placeholder="03abcd..." required {}
1029                                }
1030
1031                                div class="mb-2" {
1032                                    label class="form-label" { "Host" }
1033                                    input type="text" name="host" class="form-control" placeholder="1.2.3.4:9735" required {}
1034                                }
1035
1036                                div class="mb-2" {
1037                                    label class="form-label" { "Channel Size (sats)" }
1038                                    input type="number" name="channel_size_sats" class="form-control" placeholder="1000000" required {}
1039                                }
1040
1041                                input type="hidden" name="push_amount_sats" value="0" {}
1042
1043                                button type="submit" class="btn btn-success" { "Confirm Open" }
1044                            }
1045                        }
1046                    }
1047                }
1048            }
1049        }
1050    }
1051}
1052
1053pub async fn channels_fragment_handler<E>(
1054    State(state): State<UiState<DynGatewayApi<E>>>,
1055    _auth: UserAuth,
1056) -> Html<String>
1057where
1058    E: std::fmt::Display,
1059{
1060    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1061    let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
1062
1063    let markup = channels_fragment_markup(channels_result, None, None, is_lnd);
1064    Html(markup.into_string())
1065}
1066
1067pub async fn open_channel_handler<E: Display + Send + Sync>(
1068    State(state): State<UiState<DynGatewayApi<E>>>,
1069    _auth: UserAuth,
1070    Form(payload): Form<OpenChannelRequest>,
1071) -> Html<String> {
1072    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1073    match state.api.handle_open_channel_msg(payload).await {
1074        Ok(txid) => {
1075            let channels_result = state.api.handle_list_channels_msg().await;
1076            let markup = channels_fragment_markup(
1077                channels_result,
1078                Some(format!("Successfully initiated channel open. TxId: {txid}")),
1079                None,
1080                is_lnd,
1081            );
1082            Html(markup.into_string())
1083        }
1084        Err(err) => {
1085            let channels_result = state.api.handle_list_channels_msg().await;
1086            let markup =
1087                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1088            Html(markup.into_string())
1089        }
1090    }
1091}
1092
1093pub async fn close_channel_handler<E: Display + Send + Sync>(
1094    State(state): State<UiState<DynGatewayApi<E>>>,
1095    _auth: UserAuth,
1096    Form(payload): Form<CloseChannelsWithPeerRequest>,
1097) -> Html<String> {
1098    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1099    match state.api.handle_close_channels_with_peer_msg(payload).await {
1100        Ok(_) => {
1101            let channels_result = state.api.handle_list_channels_msg().await;
1102            let markup = channels_fragment_markup(
1103                channels_result,
1104                Some("Successfully initiated channel close".to_string()),
1105                None,
1106                is_lnd,
1107            );
1108            Html(markup.into_string())
1109        }
1110        Err(err) => {
1111            let channels_result = state.api.handle_list_channels_msg().await;
1112            let markup =
1113                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1114            Html(markup.into_string())
1115        }
1116    }
1117}
1118
1119pub async fn send_onchain_handler<E: Display + Send + Sync>(
1120    State(state): State<UiState<DynGatewayApi<E>>>,
1121    _auth: UserAuth,
1122    Form(payload): Form<SendOnchainRequest>,
1123) -> Html<String> {
1124    let result = state.api.handle_send_onchain_msg(payload).await;
1125
1126    let balances = state.api.handle_get_balances_msg().await;
1127
1128    let markup = match result {
1129        Ok(txid) => wallet_fragment_markup(
1130            &balances,
1131            Some(format!("Send transaction. TxId: {txid}")),
1132            None,
1133        ),
1134        Err(err) => wallet_fragment_markup(&balances, None, Some(err.to_string())),
1135    };
1136
1137    Html(markup.into_string())
1138}
1139
1140pub async fn wallet_fragment_handler<E>(
1141    State(state): State<UiState<DynGatewayApi<E>>>,
1142    _auth: UserAuth,
1143) -> Html<String>
1144where
1145    E: std::fmt::Display,
1146{
1147    let balances_result = state.api.handle_get_balances_msg().await;
1148    let markup = wallet_fragment_markup(&balances_result, None, None);
1149    Html(markup.into_string())
1150}
1151
1152pub async fn generate_receive_address_handler<E>(
1153    State(state): State<UiState<DynGatewayApi<E>>>,
1154    _auth: UserAuth,
1155) -> Html<String>
1156where
1157    E: std::fmt::Display,
1158{
1159    let address_result = state.api.handle_get_ln_onchain_address_msg().await;
1160
1161    let markup = match address_result {
1162        Ok(address) => {
1163            // Generate QR code SVG
1164            let code =
1165                QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
1166            let qr_svg = code.render::<svg::Color>().build();
1167
1168            html! {
1169                div class="card card-body bg-light d-flex flex-column align-items-center" {
1170                    span class="fw-bold mb-3" { "Deposit Address:" }
1171
1172                    // Flex container: address on left, QR on right
1173                    div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
1174
1175                        // Copyable input + text
1176                        div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
1177                            input type="text"
1178                                readonly
1179                                class="form-control mb-2"
1180                                style="text-align:left; font-family: monospace; font-size:1rem;"
1181                                value=(address)
1182                                onclick="copyToClipboard(this)"
1183                            {}
1184                            small class="text-muted" { "Click to copy" }
1185                        }
1186
1187                        // QR code
1188                        div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
1189                            style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
1190                        {
1191                            (PreEscaped(format!(
1192                                r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
1193                                qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
1194                            )))
1195                        }
1196                    }
1197                }
1198            }
1199        }
1200        Err(err) => {
1201            html! {
1202                div class="alert alert-danger" { "Failed to generate address: " (err) }
1203            }
1204        }
1205    };
1206
1207    Html(markup.into_string())
1208}
1209
1210pub async fn payments_fragment_handler<E>(
1211    State(state): State<UiState<DynGatewayApi<E>>>,
1212    _auth: UserAuth,
1213) -> Html<String>
1214where
1215    E: std::fmt::Display,
1216{
1217    let balances_result = state.api.handle_get_balances_msg().await;
1218    let markup = payments_fragment_markup(&balances_result, None, None, None);
1219    Html(markup.into_string())
1220}
1221
1222pub async fn create_bolt11_invoice_handler<E>(
1223    State(state): State<UiState<DynGatewayApi<E>>>,
1224    _auth: UserAuth,
1225    Form(payload): Form<CreateInvoiceForOperatorPayload>,
1226) -> Html<String>
1227where
1228    E: std::fmt::Display,
1229{
1230    let invoice_result = state
1231        .api
1232        .handle_create_invoice_for_operator_msg(payload)
1233        .await;
1234    let balances_result = state.api.handle_get_balances_msg().await;
1235
1236    match invoice_result {
1237        Ok(invoice) => {
1238            let markup =
1239                payments_fragment_markup(&balances_result, Some(invoice.to_string()), None, None);
1240            Html(markup.into_string())
1241        }
1242        Err(e) => {
1243            let markup = payments_fragment_markup(
1244                &balances_result,
1245                None,
1246                None,
1247                Some(format!("Failed to create invoice: {e}")),
1248            );
1249            Html(markup.into_string())
1250        }
1251    }
1252}
1253
1254pub async fn pay_bolt11_invoice_handler<E>(
1255    State(state): State<UiState<DynGatewayApi<E>>>,
1256    _auth: UserAuth,
1257    Form(payload): Form<PayInvoiceForOperatorPayload>,
1258) -> Html<String>
1259where
1260    E: std::fmt::Display,
1261{
1262    let send_result = state.api.handle_pay_invoice_for_operator_msg(payload).await;
1263    let balances_result = state.api.handle_get_balances_msg().await;
1264
1265    match send_result {
1266        Ok(preimage) => {
1267            let markup = payments_fragment_markup(
1268                &balances_result,
1269                None,
1270                Some(format!("Successfully paid invoice. Preimage: {preimage}")),
1271                None,
1272            );
1273            Html(markup.into_string())
1274        }
1275        Err(e) => {
1276            let markup = payments_fragment_markup(
1277                &balances_result,
1278                None,
1279                None,
1280                Some(format!("Failed to pay invoice: {e}")),
1281            );
1282            Html(markup.into_string())
1283        }
1284    }
1285}
1286
1287pub async fn transactions_fragment_handler<E>(
1288    State(state): State<UiState<DynGatewayApi<E>>>,
1289    _auth: UserAuth,
1290    Query(params): Query<HashMap<String, String>>,
1291) -> Html<String>
1292where
1293    E: std::fmt::Display + std::fmt::Debug,
1294{
1295    let now = fedimint_core::time::now();
1296    let end_secs = now
1297        .duration_since(std::time::UNIX_EPOCH)
1298        .expect("Time went backwards")
1299        .as_secs();
1300
1301    let start_secs = now
1302        .checked_sub(std::time::Duration::from_secs(60 * 60 * 24))
1303        .unwrap_or(now)
1304        .duration_since(std::time::UNIX_EPOCH)
1305        .expect("Time went backwards")
1306        .as_secs();
1307
1308    let parse = |key: &str| -> Option<u64> {
1309        params.get(key).and_then(|s| {
1310            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
1311                .ok()
1312                .map(|dt| {
1313                    let dt_utc: chrono::DateTime<Utc> = Utc.from_utc_datetime(&dt);
1314                    dt_utc.timestamp() as u64
1315                })
1316        })
1317    };
1318
1319    let start_secs = parse("start_secs").unwrap_or(start_secs);
1320    let end_secs = parse("end_secs").unwrap_or(end_secs);
1321
1322    let transactions_result = state
1323        .api
1324        .handle_list_transactions_msg(ListTransactionsPayload {
1325            start_secs,
1326            end_secs,
1327        })
1328        .await;
1329
1330    Html(transactions_fragment_markup(&transactions_result, start_secs, end_secs).into_string())
1331}