Skip to main content

fedimint_gateway_ui/
lightning.rs

1use std::collections::HashMap;
2use std::fmt::Display;
3use std::str::FromStr;
4use std::time::{Duration, UNIX_EPOCH};
5
6use axum::Form;
7use axum::extract::{Query, State};
8use axum::response::Html;
9use chrono::offset::LocalResult;
10use chrono::{DateTime, TimeZone, Utc};
11use fedimint_core::bitcoin::Network;
12use fedimint_core::time::now;
13use fedimint_gateway_common::{
14    ChannelInfo, CloseChannelsWithPeerRequest, CreateInvoiceForOperatorPayload, CreateOfferPayload,
15    GatewayBalances, GatewayInfo, LightningInfo, LightningMode, ListTransactionsPayload,
16    ListTransactionsResponse, OpenChannelRequest, PayInvoiceForOperatorPayload, PayOfferPayload,
17    PaymentStatus, SendOnchainRequest,
18};
19use fedimint_logging::LOG_GATEWAY_UI;
20use fedimint_ui_common::UiState;
21use fedimint_ui_common::auth::UserAuth;
22use lightning::offers::offer::{Amount, Offer};
23use lightning_invoice::Bolt11Invoice;
24use maud::{Markup, PreEscaped, html};
25use qrcode::QrCode;
26use qrcode::render::svg;
27use serde::Deserialize;
28use tracing::debug;
29
30use crate::{
31    CHANNEL_FRAGMENT_ROUTE, CLOSE_CHANNEL_ROUTE, CREATE_RECEIVE_INVOICE_ROUTE,
32    DETECT_PAYMENT_TYPE_ROUTE, DynGatewayApi, LN_ONCHAIN_ADDRESS_ROUTE, OPEN_CHANNEL_ROUTE,
33    PAY_UNIFIED_ROUTE, PAYMENTS_FRAGMENT_ROUTE, SEND_ONCHAIN_ROUTE, TRANSACTIONS_FRAGMENT_ROUTE,
34    WALLET_FRAGMENT_ROUTE,
35};
36
37pub async fn render<E>(gateway_info: &GatewayInfo, api: &DynGatewayApi<E>) -> Markup
38where
39    E: std::fmt::Display,
40{
41    debug!(target: LOG_GATEWAY_UI, "Listing lightning channels...");
42    // Try to load channels
43    let channels_result = api.handle_list_channels_msg().await;
44
45    let (block_height, status_badge, network, alias, pubkey) =
46        if gateway_info.gateway_state == "Syncing" {
47            (
48                0,
49                html! { span class="badge bg-warning" { "🟡 Syncing" } },
50                Network::Bitcoin,
51                None,
52                None,
53            )
54        } else {
55            match &gateway_info.lightning_info {
56                LightningInfo::Connected {
57                    network,
58                    block_height,
59                    synced_to_chain,
60                    alias,
61                    public_key,
62                } => {
63                    let badge = if *synced_to_chain {
64                        html! { span class="badge bg-success" { "🟢 Synced" } }
65                    } else {
66                        html! { span class="badge bg-warning" { "🟡 Syncing" } }
67                    };
68                    (
69                        *block_height,
70                        badge,
71                        *network,
72                        Some(alias.clone()),
73                        Some(*public_key),
74                    )
75                }
76                LightningInfo::NotConnected => (
77                    0,
78                    html! { span class="badge bg-danger" { "❌ Not Connected" } },
79                    Network::Bitcoin,
80                    None,
81                    None,
82                ),
83            }
84        };
85
86    let is_lnd = matches!(api.lightning_mode(), LightningMode::Lnd { .. });
87    debug!(target: LOG_GATEWAY_UI, "Getting all balances...");
88    let balances_result = api.handle_get_balances_msg().await;
89    let now = now();
90    let start = now
91        .checked_sub(Duration::from_secs(60 * 60 * 24))
92        .expect("Cannot be negative");
93    let start_secs = start
94        .duration_since(UNIX_EPOCH)
95        .expect("Cannot be before epoch")
96        .as_secs();
97    let end = now;
98    let end_secs = end
99        .duration_since(UNIX_EPOCH)
100        .expect("Cannot be before epoch")
101        .as_secs();
102    debug!(target: LOG_GATEWAY_UI, "Listing lightning transactions...");
103    let transactions_result = api
104        .handle_list_transactions_msg(ListTransactionsPayload {
105            start_secs,
106            end_secs,
107        })
108        .await;
109
110    html! {
111        script {
112            (PreEscaped(r#"
113            function copyToClipboard(input) {
114                input.select();
115                document.execCommand('copy');
116                const hint = input.nextElementSibling;
117                hint.textContent = 'Copied!';
118                setTimeout(() => hint.textContent = 'Click to copy', 2000);
119            }
120            "#))
121        }
122
123        div class="card h-100" {
124            div class="card-header dashboard-header" { "Lightning Node" }
125            div class="card-body" {
126
127                // --- TABS ---
128                ul class="nav nav-tabs" id="lightningTabs" role="tablist" {
129                    li class="nav-item" role="presentation" {
130                        button class="nav-link active"
131                            id="connection-tab"
132                            data-bs-toggle="tab"
133                            data-bs-target="#connection-tab-pane"
134                            type="button"
135                            role="tab"
136                        { "Connection Info" }
137                    }
138                    li class="nav-item" role="presentation" {
139                        button class="nav-link"
140                            id="wallet-tab"
141                            data-bs-toggle="tab"
142                            data-bs-target="#wallet-tab-pane"
143                            type="button"
144                            role="tab"
145                        { "Wallet" }
146                    }
147                    li class="nav-item" role="presentation" {
148                        button class="nav-link"
149                            id="channels-tab"
150                            data-bs-toggle="tab"
151                            data-bs-target="#channels-tab-pane"
152                            type="button"
153                            role="tab"
154                        { "Channels" }
155                    }
156                    li class="nav-item" role="presentation" {
157                        button class="nav-link"
158                            id="payments-tab"
159                            data-bs-toggle="tab"
160                            data-bs-target="#payments-tab-pane"
161                            type="button"
162                            role="tab"
163                        { "Payments" }
164                    }
165                    li class="nav-item" role="presentation" {
166                        button class="nav-link"
167                            id="transactions-tab"
168                            data-bs-toggle="tab"
169                            data-bs-target="#transactions-tab-pane"
170                            type="button"
171                            role="tab"
172                        { "Transactions" }
173                    }
174                }
175
176                div class="tab-content mt-3" id="lightningTabsContent" {
177
178                    // ──────────────────────────────────────────
179                    //   TAB: CONNECTION INFO
180                    // ──────────────────────────────────────────
181                    div class="tab-pane fade show active"
182                        id="connection-tab-pane"
183                        role="tabpanel"
184                        aria-labelledby="connection-tab" {
185
186                        @match &gateway_info.lightning_mode {
187                            LightningMode::Lnd { lnd_rpc_addr, lnd_tls_cert, lnd_macaroon } => {
188                                div id="node-type" class="alert alert-info" {
189                                    "Node Type: " strong { "External LND" }
190                                }
191                                table class="table table-sm mb-0" {
192                                    tbody {
193                                        tr {
194                                            th { "RPC Address" }
195                                            td { (lnd_rpc_addr) }
196                                        }
197                                        tr {
198                                            th { "TLS Cert" }
199                                            td { (lnd_tls_cert) }
200                                        }
201                                        tr {
202                                            th { "Macaroon" }
203                                            td { (lnd_macaroon) }
204                                        }
205                                        tr {
206                                            th { "Network" }
207                                            td { (network) }
208                                        }
209                                        tr {
210                                            th { "Block Height" }
211                                            td { (block_height) }
212                                        }
213                                        tr {
214                                            th { "Status" }
215                                            td { (status_badge) }
216                                        }
217                                        @if let Some(a) = alias {
218                                            tr {
219                                                th { "Alias" }
220                                                td { (a) }
221                                            }
222                                        }
223                                        @if let Some(pk) = pubkey {
224                                            tr {
225                                                th { "Public Key" }
226                                                td { (pk) }
227                                            }
228                                        }
229                                    }
230                                }
231                            }
232                            LightningMode::Ldk { lightning_port, .. } => {
233                                div id="node-type" class="alert alert-info" {
234                                    "Node Type: " strong { "Internal LDK" }
235                                }
236                                table class="table table-sm mb-0" {
237                                    tbody {
238                                        tr {
239                                            th { "Port" }
240                                            td { (lightning_port) }
241                                        }
242                                        tr {
243                                            th { "Network" }
244                                            td { (network) }
245                                        }
246                                        tr {
247                                            th { "Block Height" }
248                                            td { (block_height) }
249                                        }
250                                        tr {
251                                            th { "Status" }
252                                            td { (status_badge) }
253                                        }
254                                        @if let Some(a) = alias {
255                                            tr {
256                                                th { "Alias" }
257                                                td { (a) }
258                                            }
259                                        }
260                                        @if let Some(pk) = pubkey {
261                                            tr {
262                                                th { "Public Key" }
263                                                td { (pk) }
264                                            }
265                                        }
266                                    }
267                                }
268                            }
269                        }
270                    }
271
272                    // ──────────────────────────────────────────
273                    //   TAB: WALLET
274                    // ──────────────────────────────────────────
275                    div class="tab-pane fade"
276                        id="wallet-tab-pane"
277                        role="tabpanel"
278                        aria-labelledby="wallet-tab" {
279
280                        div class="d-flex justify-content-between align-items-center mb-2" {
281                            div { strong { "Wallet" } }
282                            button class="btn btn-sm btn-outline-secondary"
283                                hx-get=(WALLET_FRAGMENT_ROUTE)
284                                hx-target="#wallet-container"
285                                hx-swap="outerHTML"
286                                type="button"
287                            { "Refresh" }
288                        }
289
290                        (wallet_fragment_markup(&balances_result, None, None))
291                    }
292
293                    // ──────────────────────────────────────────
294                    //   TAB: CHANNELS
295                    // ──────────────────────────────────────────
296                    div class="tab-pane fade"
297                        id="channels-tab-pane"
298                        role="tabpanel"
299                        aria-labelledby="channels-tab" {
300
301                        div class="d-flex justify-content-between align-items-center mb-2" {
302                            div { strong { "Channels" } }
303                            button class="btn btn-sm btn-outline-secondary"
304                                hx-get=(CHANNEL_FRAGMENT_ROUTE)
305                                hx-target="#channels-container"
306                                hx-swap="outerHTML"
307                                type="button"
308                            { "Refresh" }
309                        }
310
311                        (channels_fragment_markup(channels_result, None, None, is_lnd))
312                    }
313
314                    // ──────────────────────────────────────────
315                    //   TAB: PAYMENTS
316                    // ──────────────────────────────────────────
317                    div class="tab-pane fade"
318                        id="payments-tab-pane"
319                        role="tabpanel"
320                        aria-labelledby="payments-tab" {
321
322                        div class="d-flex justify-content-between align-items-center mb-2" {
323                            div { strong { "Payments" } }
324                            button class="btn btn-sm btn-outline-secondary"
325                                hx-get=(PAYMENTS_FRAGMENT_ROUTE)
326                                hx-target="#payments-container"
327                                hx-swap="outerHTML"
328                                type="button"
329                            { "Refresh" }
330                        }
331
332                        (payments_fragment_markup(&balances_result, None, None, None, is_lnd))
333                    }
334
335                    // ──────────────────────────────────────────
336                    //   TAB: TRANSACTIONS
337                    // ──────────────────────────────────────────
338                    div class="tab-pane fade"
339                        id="transactions-tab-pane"
340                        role="tabpanel"
341                        aria-labelledby="transactions-tab" {
342
343                        (transactions_fragment_markup(&transactions_result, start_secs, end_secs))
344                    }
345                }
346            }
347        }
348    }
349}
350
351pub fn transactions_fragment_markup<E>(
352    transactions_result: &Result<ListTransactionsResponse, E>,
353    start_secs: u64,
354    end_secs: u64,
355) -> Markup
356where
357    E: std::fmt::Display,
358{
359    // Convert timestamps to datetime-local formatted strings
360    let start_dt = match Utc.timestamp_opt(start_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    let end_dt = match Utc.timestamp_opt(end_secs as i64, 0) {
366        LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
367        _ => "1970-01-01T00:00:00".to_string(),
368    };
369
370    html!(
371        div id="transactions-container" {
372
373            // ────────────────────────────────
374            //   Date Range Form
375            // ────────────────────────────────
376            form class="row g-3 mb-3"
377                hx-get=(TRANSACTIONS_FRAGMENT_ROUTE)
378                hx-target="#transactions-container"
379                hx-swap="outerHTML"
380            {
381                // Start
382                div class="col-auto" {
383                    label class="form-label" for="start-secs" { "Start" }
384                    input
385                        class="form-control"
386                        type="datetime-local"
387                        id="start-secs"
388                        name="start_secs"
389                        step="1"
390                        value=(start_dt);
391                }
392
393                // End
394                div class="col-auto" {
395                    label class="form-label" for="end-secs" { "End" }
396                    input
397                        class="form-control"
398                        type="datetime-local"
399                        id="end-secs"
400                        name="end_secs"
401                        step="1"
402                        value=(end_dt);
403                }
404
405                // Refresh Button
406                div class="col-auto align-self-end" {
407                    button class="btn btn-outline-secondary" type="submit" { "Refresh" }
408                    button class="btn btn-outline-secondary me-2" type="button"
409                        id="last-day-btn"
410                    { "Last Day" }
411                }
412            }
413
414            script {
415                (PreEscaped(r#"
416                document.getElementById('last-day-btn').addEventListener('click', () => {
417                    const now = new Date();
418                    const endInput = document.getElementById('end-secs');
419                    const startInput = document.getElementById('start-secs');
420
421                    const pad = n => n.toString().padStart(2, '0');
422
423                    const formatUTC = d =>
424                        `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
425
426                    endInput.value = formatUTC(now);
427
428                    const start = new Date(now.getTime() - 24*60*60*1000); // 24 hours ago UTC
429                    startInput.value = formatUTC(start);
430                });
431                "#))
432            }
433
434            // ────────────────────────────────
435            //   Transaction List
436            // ────────────────────────────────
437            @match transactions_result {
438                Err(err) => {
439                    div class="alert alert-danger" {
440                        "Failed to load lightning transactions: " (err)
441                    }
442                }
443                Ok(transactions) => {
444                    @if transactions.transactions.is_empty() {
445                        div class="alert alert-info mt-3" {
446                            "No transactions found in this time range."
447                        }
448                    } @else {
449                        ul class="list-group mt-3" {
450                            @for tx in &transactions.transactions {
451                                li class="list-group-item p-2 mb-1 transaction-item"
452                                    style="border-radius: 0.5rem; transition: background-color 0.2s;"
453                                {
454                                    // Hover effect using inline style (or add a CSS class)
455                                    div style="display: flex; justify-content: space-between; align-items: center;" {
456                                        // Left: kind + direction + status
457                                        div {
458                                            div style="font-weight: bold; font-size: 0.9rem;" {
459                                                (format!("{:?}", tx.payment_kind))
460                                                " — "
461                                                span { (format!("{:?}", tx.direction)) }
462                                            }
463
464                                            div style="font-size: 0.75rem; margin-top: 2px;" {
465                                                @let status_badge = match tx.status {
466                                                    PaymentStatus::Pending => html! { span class="badge bg-warning" { "⏳ Pending" } },
467                                                    PaymentStatus::Succeeded => html! { span class="badge bg-success" { "✅ Succeeded" } },
468                                                    PaymentStatus::Failed => html! { span class="badge bg-danger" { "❌ Failed" } },
469                                                };
470                                                (status_badge)
471                                            }
472                                        }
473
474                                        // Right: amount + timestamp
475                                        div style="text-align: right;" {
476                                            div style="font-weight: bold; font-size: 0.9rem;" {
477                                                (format!("{} sats", tx.amount.msats / 1000))
478                                            }
479                                            div style="font-size: 0.7rem; color: #6c757d;" {
480                                                @let timestamp = match Utc.timestamp_opt(tx.timestamp_secs as i64, 0) {
481                                                    LocalResult::Single(dt) => dt,
482                                                    _ => Utc.timestamp_opt(0, 0).unwrap(),
483                                                };
484                                                (timestamp.format("%Y-%m-%d %H:%M:%S").to_string())
485                                            }
486                                        }
487                                    }
488
489                                    // Optional hash/preimage, bottom row
490                                    @if let Some(hash) = &tx.payment_hash {
491                                        div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 2px;" {
492                                            "Hash: " (hash.to_string())
493                                        }
494                                    }
495
496                                    @if let Some(preimage) = &tx.preimage {
497                                        div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 1px;" {
498                                            "Preimage: " (preimage)
499                                        }
500                                    }
501
502                                    // Hover effect using inline JS (or move to CSS)
503                                    script {
504                                        (PreEscaped(r#"
505                                        const li = document.currentScript.parentElement;
506                                        li.addEventListener('mouseenter', () => li.style.backgroundColor = '#f8f9fa');
507                                        li.addEventListener('mouseleave', () => li.style.backgroundColor = 'white');
508                                        "#))
509                                    }
510                                }
511                            }
512                        }
513                    }
514                }
515            }
516        }
517    )
518}
519
520/// Results from the unified receive invoice handler
521#[derive(Default)]
522pub struct ReceiveResults {
523    /// BOLT11 invoice if generated (requires amount)
524    pub bolt11_invoice: Option<String>,
525    /// BOLT12 offer if generated (works with or without amount)
526    pub bolt12_offer: Option<String>,
527    /// Whether BOLT12 is supported (false for LND)
528    pub bolt12_supported: bool,
529    /// Error message for BOLT11 generation
530    pub bolt11_error: Option<String>,
531    /// Error message for BOLT12 generation
532    pub bolt12_error: Option<String>,
533}
534
535/// Detected payment string type for unified send
536#[derive(Debug, Clone, PartialEq)]
537pub enum PaymentStringType {
538    Bolt11,
539    Bolt12,
540    Unknown,
541}
542
543/// Detects the payment type from the payment string prefix
544fn detect_payment_type(payment_string: &str) -> PaymentStringType {
545    let lower = payment_string.trim().to_lowercase();
546
547    // BOLT11 prefixes: lnbc (mainnet), lntb (testnet), lnbcrt (regtest)
548    if lower.starts_with("lnbc") || lower.starts_with("lntb") || lower.starts_with("lnbcrt") {
549        PaymentStringType::Bolt11
550    }
551    // BOLT12 offer prefix
552    else if lower.starts_with("lno") {
553        PaymentStringType::Bolt12
554    } else {
555        PaymentStringType::Unknown
556    }
557}
558
559/// Helper to deserialize empty strings as None
560fn empty_string_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
561where
562    D: serde::Deserializer<'de>,
563    T: std::str::FromStr,
564    T::Err: std::fmt::Display,
565{
566    let opt: Option<String> = Option::deserialize(deserializer)?;
567    match opt {
568        Some(s) if s.trim().is_empty() => Ok(None),
569        Some(s) => s
570            .trim()
571            .parse::<T>()
572            .map(Some)
573            .map_err(serde::de::Error::custom),
574        None => Ok(None),
575    }
576}
577
578/// Form payload for unified receive invoice/offer generation
579#[derive(Debug, Deserialize)]
580pub struct CreateReceiveInvoicePayload {
581    /// Amount in msats (optional - required for BOLT11, optional for BOLT12)
582    #[serde(default, deserialize_with = "empty_string_as_none")]
583    pub amount_msats: Option<u64>,
584    /// Description (optional)
585    #[serde(default)]
586    pub description: Option<String>,
587}
588
589/// Form payload for unified send (BOLT11 or BOLT12)
590#[derive(Debug, Deserialize)]
591pub struct UnifiedSendPayload {
592    /// The payment string (BOLT11 invoice or BOLT12 offer)
593    pub payment_string: String,
594    /// Amount in msats (optional, for variable-amount BOLT12 offers)
595    #[serde(default, deserialize_with = "empty_string_as_none")]
596    pub amount_msats: Option<u64>,
597    /// Payer note (optional, BOLT12 only)
598    #[serde(default)]
599    pub payer_note: Option<String>,
600}
601
602/// Form payload for payment type detection
603#[derive(Debug, Deserialize)]
604pub struct DetectPaymentTypePayload {
605    /// The payment string to detect
606    pub payment_string: String,
607}
608
609/// Renders a QR code with copyable text for an invoice or offer
610fn render_qr_with_copy(value: &str, label: &str) -> Markup {
611    let code = QrCode::new(value).expect("Failed to generate QR code");
612    let qr_svg = code.render::<svg::Color>().build();
613
614    html! {
615        div class="card card-body bg-light d-flex flex-column align-items-center" {
616            span class="fw-bold mb-3" { (label) ":" }
617
618            div class="d-flex flex-row align-items-center gap-3 flex-wrap"
619                style="width: 100%;"
620            {
621                // Copyable input + text
622                div class="d-flex flex-column flex-grow-1"
623                    style="min-width: 300px;"
624                {
625                    input type="text"
626                        readonly
627                        class="form-control mb-2"
628                        style="text-align:left; font-family: monospace; font-size:1rem;"
629                        value=(value)
630                        onclick="copyToClipboard(this)"
631                    {}
632                    small class="text-muted" { "Click to copy" }
633                }
634
635                // QR code
636                div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
637                    style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
638                {
639                    (PreEscaped(format!(
640                        r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
641                        qr_svg.replace("width=", "data-width=")
642                              .replace("height=", "data-height=")
643                    )))
644                }
645            }
646        }
647    }
648}
649
650pub fn payments_fragment_markup<E>(
651    balances_result: &Result<GatewayBalances, E>,
652    receive_results: Option<&ReceiveResults>,
653    success_msg: Option<String>,
654    error_msg: Option<String>,
655    is_lnd: bool,
656) -> Markup
657where
658    E: std::fmt::Display,
659{
660    html!(
661        div id="payments-container" {
662            @match balances_result {
663                Err(err) => {
664                    // Error banner — no buttons below
665                    div class="alert alert-danger" {
666                        "Failed to load lightning balance: " (err)
667                    }
668                }
669                Ok(bal) => {
670
671                    @if let Some(success) = success_msg {
672                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
673                            span { (success) }
674                        }
675                    }
676
677                    @if let Some(error) = error_msg {
678                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
679                            span { (error) }
680                        }
681                    }
682
683                    div id="lightning-balance-banner"
684                        class="alert alert-info d-flex justify-content-between align-items-center" {
685
686                        @let lightning_balance = format!("{}", fedimint_core::Amount::from_msats(bal.lightning_balance_msats));
687
688                        span {
689                            "Lightning Balance: "
690                            strong id="lightning-balance" { (lightning_balance) }
691                        }
692                    }
693
694                    // Buttons
695                    div class="mt-3" {
696                        button class="btn btn-sm btn-outline-primary me-2"
697                            type="button"
698                            onclick="
699                                document.getElementById('receive-form').classList.add('d-none');
700                                document.getElementById('pay-invoice-form').classList.toggle('d-none');
701                            "
702                        { "Send" }
703
704                        button class="btn btn-sm btn-outline-success"
705                            type="button"
706                            onclick="
707                                document.getElementById('pay-invoice-form').classList.add('d-none');
708                                document.getElementById('receive-form').classList.toggle('d-none');
709                            "
710                        { "Receive" }
711                    }
712
713                    // Send form - Unified BOLT11/BOLT12
714                    div id="pay-invoice-form" class="card card-body mt-3 d-none" {
715                        form
716                            id="unified-send-form"
717                            hx-post=(PAY_UNIFIED_ROUTE)
718                            hx-target="#payments-container"
719                            hx-swap="outerHTML"
720                        {
721                            div class="mb-3" {
722                                @if is_lnd {
723                                    label class="form-label" for="payment_string" {
724                                        "Payment String"
725                                        small class="text-muted ms-2" { "(BOLT11 invoice)" }
726                                    }
727                                } @else {
728                                    label class="form-label" for="payment_string" {
729                                        "Payment String"
730                                        small class="text-muted ms-2" { "(BOLT11 invoice or BOLT12 offer)" }
731                                    }
732                                }
733
734                                input type="text"
735                                    class="form-control"
736                                    id="payment_string"
737                                    name="payment_string"
738                                    required
739                                    hx-post=(DETECT_PAYMENT_TYPE_ROUTE)
740                                    hx-trigger="input changed delay:500ms"
741                                    hx-target="#bolt12-fields"
742                                    hx-swap="innerHTML"
743                                    hx-include="[name='payment_string']";
744                            }
745
746                            // Dynamic fields container - populated via HTMX when BOLT12 detected
747                            div id="bolt12-fields" {}
748
749                            button
750                                type="submit"
751                                id="send-submit-btn"
752                                class="btn btn-success btn-sm"
753                            { "Pay" }
754                        }
755                    }
756
757                    // Receive form
758                    div id="receive-form" class="card card-body mt-3 d-none" {
759                        form
760                            id="create-ln-invoice-form"
761                            hx-post=(CREATE_RECEIVE_INVOICE_ROUTE)
762                            hx-target="#payments-container"
763                            hx-swap="outerHTML"
764                        {
765                            div class="mb-3" {
766                                label class="form-label" for="amount_msats" {
767                                    "Amount (msats)"
768                                    @if !is_lnd {
769                                        small class="text-muted ms-2" { "(optional for BOLT12)" }
770                                    }
771                                }
772                                input type="number"
773                                    class="form-control"
774                                    id="amount_msats"
775                                    name="amount_msats"
776                                    min="1"
777                                    placeholder="e.g. 100000";
778
779                                // Show hint about amount requirement
780                                @if is_lnd {
781                                    small class="text-muted" {
782                                        "Amount is required (BOLT12 not supported on LND)"
783                                    }
784                                }
785                            }
786
787                            div class="mb-3" {
788                                label class="form-label" for="description" { "Description (optional)" }
789                                input type="text"
790                                    class="form-control"
791                                    id="description"
792                                    name="description"
793                                    placeholder="Payment for...";
794                            }
795
796                            button
797                                type="submit"
798                                class="btn btn-success btn-sm"
799                            { "Generate Payment Request" }
800                        }
801                    }
802
803                    // ──────────────────────────────────────────
804                    //  SHOW CREATED INVOICE/OFFER WITH TABS
805                    // ──────────────────────────────────────────
806                    @if let Some(results) = receive_results {
807                        @let has_bolt11 = results.bolt11_invoice.is_some();
808                        @let has_bolt12 = results.bolt12_offer.is_some();
809                        @let show_tabs = has_bolt11 || has_bolt12 ||
810                                         results.bolt11_error.is_some() ||
811                                         results.bolt12_error.is_some();
812
813                        @if show_tabs {
814                            div class="card card-body mt-4" {
815                                // Bootstrap tabs for BOLT11 / BOLT12
816                                // Default to BOLT11 if available, otherwise BOLT12
817                                @let bolt11_is_default = has_bolt11;
818                                @let bolt12_is_default = !has_bolt11 && has_bolt12;
819
820                                ul class="nav nav-tabs" id="invoiceTabs" role="tablist" {
821                                    // BOLT11 tab
822                                    li class="nav-item" role="presentation" {
823                                        @if has_bolt11 {
824                                            button class={ @if bolt11_is_default { "nav-link active" } @else { "nav-link" } }
825                                                id="bolt11-tab"
826                                                data-bs-toggle="tab"
827                                                data-bs-target="#bolt11-pane"
828                                                type="button"
829                                                role="tab"
830                                            { "BOLT11" }
831                                        } @else {
832                                            button class="nav-link disabled"
833                                                id="bolt11-tab"
834                                                type="button"
835                                                title=(results.bolt11_error.as_deref()
836                                                    .unwrap_or("Amount required for BOLT11"))
837                                            {
838                                                "BOLT11"
839                                                span class="ms-1 text-muted" { "(unavailable)" }
840                                            }
841                                        }
842                                    }
843
844                                    // BOLT12 tab (only show if supported, i.e. not LND)
845                                    @if results.bolt12_supported {
846                                        li class="nav-item" role="presentation" {
847                                            @if has_bolt12 {
848                                                button class={ @if bolt12_is_default { "nav-link active" } @else { "nav-link" } }
849                                                    id="bolt12-tab"
850                                                    data-bs-toggle="tab"
851                                                    data-bs-target="#bolt12-pane"
852                                                    type="button"
853                                                    role="tab"
854                                                { "BOLT12" }
855                                            } @else if let Some(err) = &results.bolt12_error {
856                                                button class="nav-link disabled"
857                                                    id="bolt12-tab"
858                                                    type="button"
859                                                    title=(err)
860                                                {
861                                                    "BOLT12"
862                                                    span class="ms-1 text-muted" { "(error)" }
863                                                }
864                                            }
865                                        }
866                                    }
867                                }
868
869                                // Tab content
870                                div class="tab-content mt-3" id="invoiceTabsContent" {
871                                    // BOLT11 pane
872                                    div class={ @if bolt11_is_default { "tab-pane fade show active" } @else { "tab-pane fade" } }
873                                        id="bolt11-pane"
874                                        role="tabpanel"
875                                    {
876                                        @if let Some(invoice) = &results.bolt11_invoice {
877                                            (render_qr_with_copy(invoice, "BOLT11 Invoice"))
878                                        } @else if let Some(err) = &results.bolt11_error {
879                                            div class="alert alert-warning" {
880                                                (err)
881                                            }
882                                        } @else {
883                                            div class="alert alert-info" {
884                                                "Amount is required to generate a BOLT11 invoice."
885                                            }
886                                        }
887                                    }
888
889                                    // BOLT12 pane (only show if supported, i.e. not LND)
890                                    @if results.bolt12_supported {
891                                        div class={ @if bolt12_is_default { "tab-pane fade show active" } @else { "tab-pane fade" } }
892                                            id="bolt12-pane"
893                                            role="tabpanel"
894                                        {
895                                            @if let Some(offer) = &results.bolt12_offer {
896                                                (render_qr_with_copy(offer, "BOLT12 Offer"))
897                                            } @else if let Some(err) = &results.bolt12_error {
898                                                div class="alert alert-danger" {
899                                                    "Failed to generate BOLT12 offer: " (err)
900                                                }
901                                            }
902                                        }
903                                    }
904                                }
905                            }
906                        }
907                    }
908                }
909            }
910        }
911    )
912}
913
914pub fn wallet_fragment_markup<E>(
915    balances_result: &Result<GatewayBalances, E>,
916    success_msg: Option<String>,
917    error_msg: Option<String>,
918) -> Markup
919where
920    E: std::fmt::Display,
921{
922    html!(
923        div id="wallet-container" {
924            @match balances_result {
925                Err(err) => {
926                    // Error banner — no buttons below
927                    div class="alert alert-danger" {
928                        "Failed to load wallet balance: " (err)
929                    }
930                }
931                Ok(bal) => {
932
933                    @if let Some(success) = success_msg {
934                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
935                            span { (success) }
936                        }
937                    }
938
939                    @if let Some(error) = error_msg {
940                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
941                            span { (error) }
942                        }
943                    }
944
945                    div id="wallet-balance-banner"
946                        class="alert alert-info d-flex justify-content-between align-items-center" {
947
948                        @let onchain = format!("{}", bitcoin::Amount::from_sat(bal.onchain_balance_sats));
949
950                        span {
951                            "Balance: "
952                            strong id="wallet-balance" { (onchain) }
953                        }
954                    }
955
956                    div class="mt-3" {
957                        // Toggle Send Form button
958                        button class="btn btn-sm btn-outline-primary me-2"
959                            type="button"
960                            onclick="
961                                document.getElementById('send-form').classList.toggle('d-none');
962                                document.getElementById('receive-address-container').innerHTML = '';
963                            "
964                        { "Send" }
965
966
967                        button class="btn btn-sm btn-outline-success"
968                            hx-get=(LN_ONCHAIN_ADDRESS_ROUTE)
969                            hx-target="#receive-address-container"
970                            hx-swap="outerHTML"
971                            type="button"
972                            onclick="document.getElementById('send-form').classList.add('d-none');"
973                        { "Receive" }
974                    }
975
976                    // ──────────────────────────────────────────
977                    //   Send Form (hidden until toggled)
978                    // ──────────────────────────────────────────
979                    div id="send-form" class="card card-body mt-3 d-none" {
980
981                        form
982                            id="send-onchain-form"
983                            hx-post=(SEND_ONCHAIN_ROUTE)
984                            hx-target="#wallet-container"
985                            hx-swap="outerHTML"
986                        {
987                            // Address
988                            div class="mb-3" {
989                                label class="form-label" for="address" { "Bitcoin Address" }
990                                input
991                                    type="text"
992                                    class="form-control"
993                                    id="address"
994                                    name="address"
995                                    required;
996                            }
997
998                            // Amount + ALL button
999                            div class="mb-3" {
1000                                label class="form-label" for="amount" { "Amount (sats)" }
1001                                div class="input-group" {
1002                                    input
1003                                        type="text"
1004                                        class="form-control"
1005                                        id="amount"
1006                                        name="amount"
1007                                        placeholder="e.g. 10000 or all"
1008                                        required;
1009
1010                                    button
1011                                        class="btn btn-outline-secondary"
1012                                        type="button"
1013                                        onclick="document.getElementById('amount').value = 'all';"
1014                                    { "All" }
1015                                }
1016                            }
1017
1018                            // Fee Rate
1019                            div class="mb-3" {
1020                                label class="form-label" for="fee_rate" { "Sats per vbyte" }
1021                                input
1022                                    type="number"
1023                                    class="form-control"
1024                                    id="fee_rate"
1025                                    name="fee_rate_sats_per_vbyte"
1026                                    min="1"
1027                                    required;
1028                            }
1029
1030                            // Confirm Send
1031                            div class="mt-3" {
1032                                button
1033                                    type="submit"
1034                                    class="btn btn-sm btn-primary"
1035                                {
1036                                    "Confirm Send"
1037                                }
1038                            }
1039                        }
1040                    }
1041
1042                    div id="receive-address-container" class="mt-3" {}
1043                }
1044            }
1045        }
1046    )
1047}
1048
1049// channels_fragment_markup converts either the channels Vec or an error string
1050// into a chunk of HTML (the thing HTMX will replace).
1051pub fn channels_fragment_markup<E>(
1052    channels_result: Result<Vec<ChannelInfo>, E>,
1053    success_msg: Option<String>,
1054    error_msg: Option<String>,
1055    is_lnd: bool,
1056) -> Markup
1057where
1058    E: std::fmt::Display,
1059{
1060    html! {
1061        // This outer div is what we'll replace with hx-swap="outerHTML"
1062        div id="channels-container" {
1063            @match channels_result {
1064                Err(err_str) => {
1065                    div class="alert alert-danger" {
1066                        "Failed to load channels: " (err_str)
1067                    }
1068                }
1069                Ok(channels) => {
1070
1071                    @if let Some(success) = success_msg {
1072                        div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
1073                            span { (success) }
1074                        }
1075                    }
1076
1077                    @if let Some(error) = error_msg {
1078                        div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
1079                            span { (error) }
1080                        }
1081                    }
1082
1083                    // Total capacity summary
1084                    @let total_outbound_sats: u64 = channels.iter().map(|ch| ch.outbound_liquidity_sats).sum();
1085                    @let total_inbound_sats: u64 = channels.iter().map(|ch| ch.inbound_liquidity_sats).sum();
1086                    div class="d-flex gap-4 mb-3" {
1087                        div class="d-flex align-items-center" {
1088                            span style="display:inline-block;width:12px;height:12px;background:#28a745;margin-right:6px;border-radius:2px;" {}
1089                            strong class="me-1" { "Total Outbound:" }
1090                            (format!("{}", bitcoin::Amount::from_sat(total_outbound_sats)))
1091                        }
1092                        div class="d-flex align-items-center" {
1093                            span style="display:inline-block;width:12px;height:12px;background:#0d6efd;margin-right:6px;border-radius:2px;" {}
1094                            strong class="me-1" { "Total Inbound:" }
1095                            (format!("{}", bitcoin::Amount::from_sat(total_inbound_sats)))
1096                        }
1097                    }
1098
1099                    @if channels.is_empty() {
1100                        div class="alert alert-info" { "No channels found." }
1101                    } @else {
1102                        div class="table-responsive" {
1103                        table class="table table-sm align-middle" {
1104                            thead {
1105                                tr {
1106                                    th { "Remote PubKey" }
1107                                    th { "Alias" }
1108                                    th { "Host" }
1109                                    th { "Funding OutPoint" }
1110                                    th { "Size (sats)" }
1111                                    th { "Active" }
1112                                    th { "Liquidity" }
1113                                    th { "" }
1114                                }
1115                            }
1116                            tbody {
1117                                @for ch in channels {
1118                                    @let row_id = format!("close-form-{}", ch.remote_pubkey);
1119                                    // precompute safely (no @let inline arithmetic)
1120                                    @let size = ch.channel_size_sats.max(1);
1121                                    @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
1122                                    @let inbound_pct  = (ch.inbound_liquidity_sats  as f64 / size as f64) * 100.0;
1123                                    @let funding_outpoint = if let Some(funding_outpoint) = ch.funding_outpoint {
1124                                        funding_outpoint.to_string()
1125                                    } else {
1126                                        "".to_string()
1127                                    };
1128                                    // Abbreviated versions for display (8 chars each side)
1129                                    @let pubkey_str = ch.remote_pubkey.to_string();
1130                                    @let pubkey_abbrev = format!("{}...{}", &pubkey_str[..8], &pubkey_str[pubkey_str.len()-8..]);
1131                                    @let funding_abbrev = if funding_outpoint.len() > 20 {
1132                                        format!("{}...{}", &funding_outpoint[..8], &funding_outpoint[funding_outpoint.len()-8..])
1133                                    } else {
1134                                        funding_outpoint.clone()
1135                                    };
1136
1137                                    tr {
1138                                        td {
1139                                            span
1140                                                class="text-abbrev-copy"
1141                                                title=(format!("{} (click to copy)", pubkey_str))
1142                                                data-original=(pubkey_abbrev)
1143                                                onclick=(format!("navigator.clipboard.writeText('{}').then(() => {{ const el = this; el.textContent = 'Copied!'; setTimeout(() => el.textContent = el.dataset.original, 1000); }});", pubkey_str))
1144                                            {
1145                                                (pubkey_abbrev)
1146                                            }
1147                                        }
1148                                        td {
1149                                            @if let Some(alias) = &ch.remote_node_alias {
1150                                                (alias)
1151                                            } @else {
1152                                                span class="text-muted" { "-" }
1153                                            }
1154                                        }
1155                                        td {
1156                                            @if let Some(addr) = &ch.remote_address {
1157                                                (addr)
1158                                            } @else {
1159                                                span class="text-muted" { "-" }
1160                                            }
1161                                        }
1162                                        td {
1163                                            @if !funding_outpoint.is_empty() {
1164                                                span
1165                                                    class="text-abbrev-copy"
1166                                                    title=(format!("{} (click to copy)", funding_outpoint))
1167                                                    data-original=(funding_abbrev)
1168                                                    onclick=(format!("navigator.clipboard.writeText('{}').then(() => {{ const el = this; el.textContent = 'Copied!'; setTimeout(() => el.textContent = el.dataset.original, 1000); }});", funding_outpoint))
1169                                                {
1170                                                    (funding_abbrev)
1171                                                }
1172                                            }
1173                                        }
1174                                        td { (ch.channel_size_sats) }
1175                                        td {
1176                                            @if ch.is_active {
1177                                                span class="badge bg-success" { "active" }
1178                                            } @else {
1179                                                span class="badge bg-secondary" { "inactive" }
1180                                            }
1181                                        }
1182
1183                                        // Liquidity bar: single horizontal bar split by two divs
1184                                        td {
1185                                            div style="width:240px;" {
1186                                                div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
1187                                                    div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
1188                                                    div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
1189                                                }
1190
1191                                                div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
1192                                                    span {
1193                                                        span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
1194                                                        (format!("Outbound ({})", ch.outbound_liquidity_sats))
1195                                                    }
1196                                                    span {
1197                                                        span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
1198                                                        (format!("Inbound ({})", ch.inbound_liquidity_sats))
1199                                                    }
1200                                                }
1201                                            }
1202                                        }
1203
1204                                        td style="width: 110px" {
1205                                            @let open_row_id = format!("open-form-{}", ch.remote_pubkey);
1206                                            // + button to open another channel with this peer
1207                                            button class="btn btn-sm btn-outline-primary me-1"
1208                                                type="button"
1209                                                data-bs-toggle="collapse"
1210                                                data-bs-target=(format!("#{open_row_id}"))
1211                                                aria-expanded="false"
1212                                                aria-controls=(open_row_id)
1213                                            { "+" }
1214                                            // X button toggles a per-row collapse
1215                                            button class="btn btn-sm btn-outline-danger"
1216                                                type="button"
1217                                                data-bs-toggle="collapse"
1218                                                data-bs-target=(format!("#{row_id}"))
1219                                                aria-expanded="false"
1220                                                aria-controls=(row_id)
1221                                            { "X" }
1222                                        }
1223                                    }
1224
1225                                    // Collapsible open channel form for this peer
1226                                    tr class="collapse" id=(format!("open-form-{}", ch.remote_pubkey)) {
1227                                        td colspan="8" {
1228                                            div class="card card-body" {
1229                                                form
1230                                                    hx-post=(OPEN_CHANNEL_ROUTE)
1231                                                    hx-target="#channels-container"
1232                                                    hx-swap="outerHTML"
1233                                                {
1234                                                    h6 class="card-title" {
1235                                                        "Open New Channel with "
1236                                                        @if let Some(alias) = &ch.remote_node_alias {
1237                                                            (alias)
1238                                                        } @else {
1239                                                            (format!("{}...{}", &pubkey_str[..8], &pubkey_str[pubkey_str.len()-8..]))
1240                                                        }
1241                                                    }
1242
1243                                                    input type="hidden"
1244                                                        name="pubkey"
1245                                                        value=(ch.remote_pubkey.to_string()) {}
1246
1247                                                    @if let Some(addr) = &ch.remote_address {
1248                                                        input type="hidden"
1249                                                            name="host"
1250                                                            value=(addr) {}
1251                                                    } @else {
1252                                                        div class="mb-2" {
1253                                                            label class="form-label" { "Host" }
1254                                                            input type="text"
1255                                                                name="host"
1256                                                                class="form-control"
1257                                                                placeholder="1.2.3.4:9735"
1258                                                                required {}
1259                                                        }
1260                                                    }
1261
1262                                                    div class="mb-2" {
1263                                                        label class="form-label" { "Channel Size (sats)" }
1264                                                        input type="number"
1265                                                            name="channel_size_sats"
1266                                                            class="form-control"
1267                                                            placeholder="1000000"
1268                                                            required {}
1269                                                    }
1270
1271                                                    input type="hidden" name="push_amount_sats" value="0" {}
1272
1273                                                    button type="submit"
1274                                                        class="btn btn-success btn-sm" {
1275                                                        "Confirm Open"
1276                                                    }
1277                                                }
1278                                            }
1279                                        }
1280                                    }
1281
1282                                    tr class="collapse" id=(row_id) {
1283                                        td colspan="8" {
1284                                            div class="card card-body" {
1285                                                form
1286                                                    hx-post=(CLOSE_CHANNEL_ROUTE)
1287                                                    hx-target="#channels-container"
1288                                                    hx-swap="outerHTML"
1289                                                    hx-indicator=(format!("#close-spinner-{}", ch.remote_pubkey))
1290                                                    hx-disabled-elt="button[type='submit']"
1291                                                {
1292                                                    // always required
1293                                                    input type="hidden"
1294                                                        name="pubkey"
1295                                                        value=(ch.remote_pubkey.to_string()) {}
1296
1297                                                    div class="form-check mb-3" {
1298                                                        input class="form-check-input"
1299                                                            type="checkbox"
1300                                                            name="force"
1301                                                            value="true"
1302                                                            id=(format!("force-{}", ch.remote_pubkey))
1303                                                            onchange=(format!(
1304                                                                "const input = document.getElementById('sats-vb-{}'); \
1305                                                                input.disabled = this.checked;",
1306                                                                ch.remote_pubkey
1307                                                            )) {}
1308                                                        label class="form-check-label"
1309                                                            for=(format!("force-{}", ch.remote_pubkey)) {
1310                                                            "Force Close"
1311                                                        }
1312                                                    }
1313
1314                                                    // -------------------------------------------
1315                                                    // CONDITIONAL sats/vbyte input
1316                                                    // -------------------------------------------
1317                                                    @if is_lnd {
1318                                                        div class="mb-3" id=(format!("sats-vb-div-{}", ch.remote_pubkey)) {
1319                                                            label class="form-label" for=(format!("sats-vb-{}", ch.remote_pubkey)) {
1320                                                                "Sats per vbyte"
1321                                                            }
1322                                                            input
1323                                                                type="number"
1324                                                                min="1"
1325                                                                step="1"
1326                                                                class="form-control"
1327                                                                id=(format!("sats-vb-{}", ch.remote_pubkey))
1328                                                                name="sats_per_vbyte"
1329                                                                required
1330                                                                placeholder="Enter fee rate" {}
1331
1332                                                            small class="text-muted" {
1333                                                                "Required for LND fee estimation"
1334                                                            }
1335                                                        }
1336                                                    } @else {
1337                                                        // LDK → auto-filled, hidden
1338                                                        input type="hidden"
1339                                                            name="sats_per_vbyte"
1340                                                            value="1" {}
1341                                                    }
1342
1343                                                    // spinner for this specific channel
1344                                                    div class="htmx-indicator mt-2"
1345                                                        id=(format!("close-spinner-{}", ch.remote_pubkey)) {
1346                                                        div class="spinner-border spinner-border-sm text-danger" role="status" {}
1347                                                        span { " Closing..." }
1348                                                    }
1349
1350                                                    button type="submit"
1351                                                        class="btn btn-danger btn-sm" {
1352                                                        "Confirm Close"
1353                                                    }
1354                                                }
1355                                            }
1356                                        }
1357                                    }
1358                                }
1359                            }
1360                        }
1361                        }
1362                    }
1363
1364                    div class="mt-3" {
1365                        // Toggle button
1366                        button id="open-channel-btn" class="btn btn-sm btn-primary"
1367                            type="button"
1368                            data-bs-toggle="collapse"
1369                            data-bs-target="#open-channel-form"
1370                            aria-expanded="false"
1371                            aria-controls="open-channel-form"
1372                        { "Open Channel" }
1373
1374                        // Collapsible form
1375                        div id="open-channel-form" class="collapse mt-3" {
1376                            form hx-post=(OPEN_CHANNEL_ROUTE)
1377                                hx-target="#channels-container"
1378                                hx-swap="outerHTML"
1379                                class="card card-body" {
1380
1381                                h5 class="card-title" { "Open New Channel" }
1382
1383                                div class="mb-2" {
1384                                    label class="form-label" { "Remote Node Public Key" }
1385                                    input type="text" name="pubkey" class="form-control" placeholder="03abcd..." required {}
1386                                }
1387
1388                                div class="mb-2" {
1389                                    label class="form-label" { "Host" }
1390                                    input type="text" name="host" class="form-control" placeholder="1.2.3.4:9735" required {}
1391                                }
1392
1393                                div class="mb-2" {
1394                                    label class="form-label" { "Channel Size (sats)" }
1395                                    input type="number" name="channel_size_sats" class="form-control" placeholder="1000000" required {}
1396                                }
1397
1398                                input type="hidden" name="push_amount_sats" value="0" {}
1399
1400                                button type="submit" class="btn btn-success" { "Confirm Open" }
1401                            }
1402                        }
1403                    }
1404                }
1405            }
1406        }
1407    }
1408}
1409
1410pub async fn channels_fragment_handler<E>(
1411    State(state): State<UiState<DynGatewayApi<E>>>,
1412    _auth: UserAuth,
1413) -> Html<String>
1414where
1415    E: std::fmt::Display,
1416{
1417    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1418    let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
1419
1420    let markup = channels_fragment_markup(channels_result, None, None, is_lnd);
1421    Html(markup.into_string())
1422}
1423
1424pub async fn open_channel_handler<E: Display + Send + Sync>(
1425    State(state): State<UiState<DynGatewayApi<E>>>,
1426    _auth: UserAuth,
1427    Form(payload): Form<OpenChannelRequest>,
1428) -> Html<String> {
1429    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1430    match state.api.handle_open_channel_msg(payload).await {
1431        Ok(txid) => {
1432            let channels_result = state.api.handle_list_channels_msg().await;
1433            let markup = channels_fragment_markup(
1434                channels_result,
1435                Some(format!("Successfully initiated channel open. TxId: {txid}")),
1436                None,
1437                is_lnd,
1438            );
1439            Html(markup.into_string())
1440        }
1441        Err(err) => {
1442            let channels_result = state.api.handle_list_channels_msg().await;
1443            let markup =
1444                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1445            Html(markup.into_string())
1446        }
1447    }
1448}
1449
1450pub async fn close_channel_handler<E: Display + Send + Sync>(
1451    State(state): State<UiState<DynGatewayApi<E>>>,
1452    _auth: UserAuth,
1453    Form(payload): Form<CloseChannelsWithPeerRequest>,
1454) -> Html<String> {
1455    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1456    match state.api.handle_close_channels_with_peer_msg(payload).await {
1457        Ok(_) => {
1458            let channels_result = state.api.handle_list_channels_msg().await;
1459            let markup = channels_fragment_markup(
1460                channels_result,
1461                Some("Successfully initiated channel close".to_string()),
1462                None,
1463                is_lnd,
1464            );
1465            Html(markup.into_string())
1466        }
1467        Err(err) => {
1468            let channels_result = state.api.handle_list_channels_msg().await;
1469            let markup =
1470                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1471            Html(markup.into_string())
1472        }
1473    }
1474}
1475
1476pub async fn send_onchain_handler<E: Display + Send + Sync>(
1477    State(state): State<UiState<DynGatewayApi<E>>>,
1478    _auth: UserAuth,
1479    Form(payload): Form<SendOnchainRequest>,
1480) -> Html<String> {
1481    let result = state.api.handle_send_onchain_msg(payload).await;
1482
1483    let balances = state.api.handle_get_balances_msg().await;
1484
1485    let markup = match result {
1486        Ok(txid) => wallet_fragment_markup(
1487            &balances,
1488            Some(format!("Send transaction. TxId: {txid}")),
1489            None,
1490        ),
1491        Err(err) => wallet_fragment_markup(&balances, None, Some(err.to_string())),
1492    };
1493
1494    Html(markup.into_string())
1495}
1496
1497pub async fn wallet_fragment_handler<E>(
1498    State(state): State<UiState<DynGatewayApi<E>>>,
1499    _auth: UserAuth,
1500) -> Html<String>
1501where
1502    E: std::fmt::Display,
1503{
1504    let balances_result = state.api.handle_get_balances_msg().await;
1505    let markup = wallet_fragment_markup(&balances_result, None, None);
1506    Html(markup.into_string())
1507}
1508
1509pub async fn generate_receive_address_handler<E>(
1510    State(state): State<UiState<DynGatewayApi<E>>>,
1511    _auth: UserAuth,
1512) -> Html<String>
1513where
1514    E: std::fmt::Display,
1515{
1516    let address_result = state.api.handle_get_ln_onchain_address_msg().await;
1517
1518    let markup = match address_result {
1519        Ok(address) => {
1520            // Generate QR code SVG
1521            let code =
1522                QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
1523            let qr_svg = code.render::<svg::Color>().build();
1524
1525            html! {
1526                div class="card card-body bg-light d-flex flex-column align-items-center" {
1527                    span class="fw-bold mb-3" { "Deposit Address:" }
1528
1529                    // Flex container: address on left, QR on right
1530                    div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
1531
1532                        // Copyable input + text
1533                        div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
1534                            input type="text"
1535                                readonly
1536                                class="form-control mb-2"
1537                                style="text-align:left; font-family: monospace; font-size:1rem;"
1538                                value=(address)
1539                                onclick="copyToClipboard(this)"
1540                            {}
1541                            small class="text-muted" { "Click to copy" }
1542                        }
1543
1544                        // QR code
1545                        div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
1546                            style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
1547                        {
1548                            (PreEscaped(format!(
1549                                r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
1550                                qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
1551                            )))
1552                        }
1553                    }
1554                }
1555            }
1556        }
1557        Err(err) => {
1558            html! {
1559                div class="alert alert-danger" { "Failed to generate address: " (err) }
1560            }
1561        }
1562    };
1563
1564    Html(markup.into_string())
1565}
1566
1567pub async fn payments_fragment_handler<E>(
1568    State(state): State<UiState<DynGatewayApi<E>>>,
1569    _auth: UserAuth,
1570) -> Html<String>
1571where
1572    E: std::fmt::Display,
1573{
1574    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1575    let balances_result = state.api.handle_get_balances_msg().await;
1576    let markup = payments_fragment_markup(&balances_result, None, None, None, is_lnd);
1577    Html(markup.into_string())
1578}
1579
1580pub async fn create_bolt11_invoice_handler<E>(
1581    State(state): State<UiState<DynGatewayApi<E>>>,
1582    _auth: UserAuth,
1583    Form(payload): Form<CreateInvoiceForOperatorPayload>,
1584) -> Html<String>
1585where
1586    E: std::fmt::Display,
1587{
1588    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1589    let invoice_result = state
1590        .api
1591        .handle_create_invoice_for_operator_msg(payload)
1592        .await;
1593    let balances_result = state.api.handle_get_balances_msg().await;
1594
1595    match invoice_result {
1596        Ok(invoice) => {
1597            let results = ReceiveResults {
1598                bolt11_invoice: Some(invoice.to_string()),
1599                bolt12_supported: !is_lnd,
1600                ..Default::default()
1601            };
1602            let markup =
1603                payments_fragment_markup(&balances_result, Some(&results), None, None, is_lnd);
1604            Html(markup.into_string())
1605        }
1606        Err(e) => {
1607            let markup = payments_fragment_markup(
1608                &balances_result,
1609                None,
1610                None,
1611                Some(format!("Failed to create invoice: {e}")),
1612                is_lnd,
1613            );
1614            Html(markup.into_string())
1615        }
1616    }
1617}
1618
1619pub async fn create_receive_invoice_handler<E>(
1620    State(state): State<UiState<DynGatewayApi<E>>>,
1621    _auth: UserAuth,
1622    Form(payload): Form<CreateReceiveInvoicePayload>,
1623) -> Html<String>
1624where
1625    E: std::fmt::Display,
1626{
1627    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1628    let has_amount = payload.amount_msats.is_some() && payload.amount_msats != Some(0);
1629
1630    let mut results = ReceiveResults {
1631        bolt12_supported: !is_lnd,
1632        ..Default::default()
1633    };
1634
1635    // Validate: LND requires amount
1636    if is_lnd && !has_amount {
1637        let balances_result = state.api.handle_get_balances_msg().await;
1638        let markup = payments_fragment_markup(
1639            &balances_result,
1640            None,
1641            None,
1642            Some("Amount is required when using LND (BOLT12 not supported)".to_string()),
1643            is_lnd,
1644        );
1645        return Html(markup.into_string());
1646    }
1647
1648    // Generate BOLT11 if amount is provided
1649    if let Some(amount_msats) = payload.amount_msats {
1650        if amount_msats > 0 {
1651            let bolt11_payload = CreateInvoiceForOperatorPayload {
1652                amount_msats,
1653                expiry_secs: None,
1654                description: payload.description.clone(),
1655            };
1656
1657            match state
1658                .api
1659                .handle_create_invoice_for_operator_msg(bolt11_payload)
1660                .await
1661            {
1662                Ok(invoice) => {
1663                    results.bolt11_invoice = Some(invoice.to_string());
1664                }
1665                Err(e) => {
1666                    results.bolt11_error = Some(format!("Failed to create BOLT11: {e}"));
1667                }
1668            }
1669        }
1670    } else {
1671        results.bolt11_error = Some("Amount required for BOLT11 invoice".to_string());
1672    }
1673
1674    // Generate BOLT12 offer if not LND
1675    if !is_lnd {
1676        let bolt12_payload = CreateOfferPayload {
1677            amount: payload.amount_msats.and_then(|a| {
1678                if a > 0 {
1679                    Some(fedimint_core::Amount::from_msats(a))
1680                } else {
1681                    None
1682                }
1683            }),
1684            description: payload.description,
1685            expiry_secs: None,
1686            quantity: None,
1687        };
1688
1689        match state
1690            .api
1691            .handle_create_offer_for_operator_msg(bolt12_payload)
1692            .await
1693        {
1694            Ok(response) => {
1695                results.bolt12_offer = Some(response.offer);
1696            }
1697            Err(e) => {
1698                results.bolt12_error = Some(e.to_string());
1699            }
1700        }
1701    }
1702
1703    let balances_result = state.api.handle_get_balances_msg().await;
1704    let markup = payments_fragment_markup(&balances_result, Some(&results), None, None, is_lnd);
1705    Html(markup.into_string())
1706}
1707
1708pub async fn pay_bolt11_invoice_handler<E>(
1709    State(state): State<UiState<DynGatewayApi<E>>>,
1710    _auth: UserAuth,
1711    Form(payload): Form<PayInvoiceForOperatorPayload>,
1712) -> Html<String>
1713where
1714    E: std::fmt::Display,
1715{
1716    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1717    let send_result = state.api.handle_pay_invoice_for_operator_msg(payload).await;
1718    let balances_result = state.api.handle_get_balances_msg().await;
1719
1720    match send_result {
1721        Ok(preimage) => {
1722            let markup = payments_fragment_markup(
1723                &balances_result,
1724                None,
1725                Some(format!("Successfully paid invoice. Preimage: {preimage}")),
1726                None,
1727                is_lnd,
1728            );
1729            Html(markup.into_string())
1730        }
1731        Err(e) => {
1732            let markup = payments_fragment_markup(
1733                &balances_result,
1734                None,
1735                None,
1736                Some(format!("Failed to pay invoice: {e}")),
1737                is_lnd,
1738            );
1739            Html(markup.into_string())
1740        }
1741    }
1742}
1743
1744/// Handler to detect payment type and return appropriate form fields
1745pub async fn detect_payment_type_handler<E>(
1746    State(state): State<UiState<DynGatewayApi<E>>>,
1747    _auth: UserAuth,
1748    Form(payload): Form<DetectPaymentTypePayload>,
1749) -> Html<String>
1750where
1751    E: std::fmt::Display,
1752{
1753    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1754    let payment_type = detect_payment_type(&payload.payment_string);
1755
1756    let markup = match payment_type {
1757        PaymentStringType::Bolt12 if is_lnd => {
1758            // LND cannot pay BOLT12 - show error
1759            html! {
1760                div class="alert alert-danger mt-2" {
1761                    strong { "BOLT12 offers are not supported with LND." }
1762                    p class="mb-0 mt-1" { "Please use a BOLT11 invoice instead." }
1763                }
1764                script {
1765                    (PreEscaped("document.getElementById('send-submit-btn').disabled = true;"))
1766                }
1767            }
1768        }
1769        PaymentStringType::Bolt12 => {
1770            // Show optional BOLT12 fields
1771            let offer = Offer::from_str(&payload.payment_string);
1772
1773            if let Ok(offer) = offer {
1774                html! {
1775                    div class="mt-3 p-2 bg-light rounded" {
1776
1777                        @match offer.amount() {
1778                            Some(Amount::Bitcoin { amount_msats }) => {
1779                                div class="mb-2" {
1780                                    label class="form-label" for="amount_msats" {
1781                                        "Amount (msats)"
1782                                        small class="text-muted ms-2" { "(fixed by offer)" }
1783                                    }
1784
1785                                    input
1786                                        type="number"
1787                                        class="form-control"
1788                                        id="amount_msats"
1789                                        name="amount_msats"
1790                                        value=(amount_msats)
1791                                        readonly
1792                                        ;
1793                                }
1794                            }
1795                            Some(_) => {
1796                                div class="alert alert-danger mb-2" {
1797                                    strong { "Unsupported offer currency." }
1798                                    " Only Bitcoin-denominated BOLT12 offers are supported."
1799                                }
1800                            }
1801                            None => {
1802                                div class="mb-2" {
1803                                    label class="form-label" for="amount_msats" {
1804                                        "Amount (msats)"
1805                                        small class="text-muted ms-2" { "(required)" }
1806                                    }
1807
1808                                    input
1809                                        type="number"
1810                                        class="form-control"
1811                                        id="amount_msats"
1812                                        name="amount_msats"
1813                                        min="1"
1814                                        placeholder="Enter amount in msats"
1815                                        ;
1816                                }
1817                            }
1818                        }
1819
1820                        // Only show payer note if we didn't hit an error
1821                        @if matches!(offer.amount(), Some(Amount::Bitcoin { .. }) | None) {
1822                            div class="mb-2" {
1823                                label class="form-label" for="payer_note" {
1824                                    "Payer Note"
1825                                    small class="text-muted ms-2" { "(optional)" }
1826                                }
1827                                input
1828                                    type="text"
1829                                    class="form-control"
1830                                    id="payer_note"
1831                                    name="payer_note"
1832                                    placeholder="Optional note to recipient"
1833                                    ;
1834                            }
1835                        }
1836                    }
1837
1838                    // Enable submit only if the offer is usable
1839                    @if matches!(offer.amount(), Some(Amount::Bitcoin { .. }) | None) {
1840                        script {
1841                            (PreEscaped(
1842                                "document.getElementById('send-submit-btn').disabled = false;"
1843                            ))
1844                        }
1845                    }
1846                }
1847            } else {
1848                html! {
1849                    div class="alert alert-warning mt-2" {
1850                        small { "Invalid BOLT12 Offer" }
1851                    }
1852                }
1853            }
1854        }
1855        PaymentStringType::Bolt11 => {
1856            // Clear any previous BOLT12 fields, enable submit
1857            let bolt11 = Bolt11Invoice::from_str(&payload.payment_string);
1858            if let Ok(bolt11) = bolt11 {
1859                let amount = bolt11.amount_milli_satoshis();
1860                let payee_pub_key = bolt11.payee_pub_key();
1861                let payment_hash = bolt11.payment_hash();
1862                let expires_at = bolt11.expires_at();
1863
1864                html! {
1865                    div class="mt-3 p-2 bg-light rounded" {
1866                        div class="mb-2" {
1867                            strong { "Amount: " }
1868                            @match amount {
1869                                Some(msats) => {
1870                                    span { (format!("{msats} msats")) }
1871                                }
1872                                None => {
1873                                    span class="text-muted" { "Amount not specified" }
1874                                }
1875                            }
1876                        }
1877
1878                        div class="mb-2" {
1879                            strong { "Payee Public Key: " }
1880                            @match payee_pub_key {
1881                                Some(pk) => {
1882                                    code { (pk.to_string()) }
1883                                }
1884                                None => {
1885                                    span class="text-muted" { "Not provided" }
1886                                }
1887                            }
1888                        }
1889
1890                        div class="mb-2" {
1891                            strong { "Payment Hash: " }
1892                            code { (payment_hash.to_string()) }
1893                        }
1894
1895                        div class="mb-2" {
1896                            strong { "Expires At: " }
1897                            @match expires_at {
1898                                Some(unix_ts) => {
1899                                    @let datetime: DateTime<Utc> =
1900                                        DateTime::<Utc>::from(UNIX_EPOCH + unix_ts);
1901                                    span {
1902                                        (datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1903                                    }
1904                                }
1905                                None => {
1906                                    span class="text-muted" { "No expiry" }
1907                                }
1908                            }
1909                        }
1910                    }
1911
1912                    script {
1913                        (PreEscaped("document.getElementById('send-submit-btn').disabled = false;"))
1914                    }
1915                }
1916            } else {
1917                html! {
1918                    div class="alert alert-warning mt-2" {
1919                        small { "Invalid BOLT11 Invoice" }
1920                    }
1921                }
1922            }
1923        }
1924        PaymentStringType::Unknown => {
1925            // Show validation hint if there's some input
1926            if payload.payment_string.trim().is_empty() {
1927                html! {}
1928            } else {
1929                html! {
1930                    div class="alert alert-warning mt-2" {
1931                        small { "Could not detect payment type. Please paste a valid BOLT11 invoice (starting with lnbc/lntb/lnbcrt) or BOLT12 offer (starting with lno)." }
1932                    }
1933                }
1934            }
1935        }
1936    };
1937
1938    Html(markup.into_string())
1939}
1940
1941/// Unified handler for paying BOLT11 invoices or BOLT12 offers
1942pub async fn pay_unified_handler<E>(
1943    State(state): State<UiState<DynGatewayApi<E>>>,
1944    _auth: UserAuth,
1945    Form(payload): Form<UnifiedSendPayload>,
1946) -> Html<String>
1947where
1948    E: std::fmt::Display,
1949{
1950    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1951    let payment_type = detect_payment_type(&payload.payment_string);
1952    let balances_result = state.api.handle_get_balances_msg().await;
1953
1954    match payment_type {
1955        PaymentStringType::Bolt12 if is_lnd => {
1956            // Should not happen if detection works, but handle gracefully
1957            let markup = payments_fragment_markup(
1958                &balances_result,
1959                None,
1960                None,
1961                Some(
1962                    "BOLT12 offers are not supported with LND. Please use a BOLT11 invoice."
1963                        .to_string(),
1964                ),
1965                is_lnd,
1966            );
1967            Html(markup.into_string())
1968        }
1969        PaymentStringType::Bolt12 => {
1970            // Pay BOLT12 offer
1971            let offer_payload = PayOfferPayload {
1972                offer: payload.payment_string,
1973                amount: payload.amount_msats.map(fedimint_core::Amount::from_msats),
1974                quantity: None,
1975                payer_note: payload.payer_note,
1976            };
1977
1978            match state
1979                .api
1980                .handle_pay_offer_for_operator_msg(offer_payload)
1981                .await
1982            {
1983                Ok(response) => {
1984                    let markup = payments_fragment_markup(
1985                        &balances_result,
1986                        None,
1987                        Some(format!(
1988                            "Successfully paid BOLT12 offer. Preimage: {}",
1989                            response.preimage
1990                        )),
1991                        None,
1992                        is_lnd,
1993                    );
1994                    Html(markup.into_string())
1995                }
1996                Err(e) => {
1997                    let markup = payments_fragment_markup(
1998                        &balances_result,
1999                        None,
2000                        None,
2001                        Some(format!("Failed to pay BOLT12 offer: {e}")),
2002                        is_lnd,
2003                    );
2004                    Html(markup.into_string())
2005                }
2006            }
2007        }
2008        PaymentStringType::Bolt11 => {
2009            // Parse and pay BOLT11 invoice
2010            match payload
2011                .payment_string
2012                .trim()
2013                .parse::<lightning_invoice::Bolt11Invoice>()
2014            {
2015                Ok(invoice) => {
2016                    let bolt11_payload = PayInvoiceForOperatorPayload { invoice };
2017                    match state
2018                        .api
2019                        .handle_pay_invoice_for_operator_msg(bolt11_payload)
2020                        .await
2021                    {
2022                        Ok(preimage) => {
2023                            let markup = payments_fragment_markup(
2024                                &balances_result,
2025                                None,
2026                                Some(format!("Successfully paid invoice. Preimage: {preimage}")),
2027                                None,
2028                                is_lnd,
2029                            );
2030                            Html(markup.into_string())
2031                        }
2032                        Err(e) => {
2033                            let markup = payments_fragment_markup(
2034                                &balances_result,
2035                                None,
2036                                None,
2037                                Some(format!("Failed to pay invoice: {e}")),
2038                                is_lnd,
2039                            );
2040                            Html(markup.into_string())
2041                        }
2042                    }
2043                }
2044                Err(e) => {
2045                    let markup = payments_fragment_markup(
2046                        &balances_result,
2047                        None,
2048                        None,
2049                        Some(format!("Invalid BOLT11 invoice: {e}")),
2050                        is_lnd,
2051                    );
2052                    Html(markup.into_string())
2053                }
2054            }
2055        }
2056        PaymentStringType::Unknown => {
2057            let markup = payments_fragment_markup(
2058                &balances_result,
2059                None,
2060                None,
2061                Some("Could not detect payment type. Please provide a valid BOLT11 invoice or BOLT12 offer.".to_string()),
2062                is_lnd,
2063            );
2064            Html(markup.into_string())
2065        }
2066    }
2067}
2068
2069pub async fn transactions_fragment_handler<E>(
2070    State(state): State<UiState<DynGatewayApi<E>>>,
2071    _auth: UserAuth,
2072    Query(params): Query<HashMap<String, String>>,
2073) -> Html<String>
2074where
2075    E: std::fmt::Display + std::fmt::Debug,
2076{
2077    let now = fedimint_core::time::now();
2078    let end_secs = now
2079        .duration_since(std::time::UNIX_EPOCH)
2080        .expect("Time went backwards")
2081        .as_secs();
2082
2083    let start_secs = now
2084        .checked_sub(std::time::Duration::from_secs(60 * 60 * 24))
2085        .unwrap_or(now)
2086        .duration_since(std::time::UNIX_EPOCH)
2087        .expect("Time went backwards")
2088        .as_secs();
2089
2090    let parse = |key: &str| -> Option<u64> {
2091        params.get(key).and_then(|s| {
2092            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
2093                .ok()
2094                .map(|dt| {
2095                    let dt_utc: chrono::DateTime<Utc> = Utc.from_utc_datetime(&dt);
2096                    dt_utc.timestamp() as u64
2097                })
2098        })
2099    };
2100
2101    let start_secs = parse("start_secs").unwrap_or(start_secs);
2102    let end_secs = parse("end_secs").unwrap_or(end_secs);
2103
2104    let transactions_result = state
2105        .api
2106        .handle_list_transactions_msg(ListTransactionsPayload {
2107            start_secs,
2108            end_secs,
2109        })
2110        .await;
2111
2112    Html(transactions_fragment_markup(&transactions_result, start_secs, end_secs).into_string())
2113}