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