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