Skip to main content

fedimint_gateway_ui/
lightning.rs

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