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                        div class="table-responsive" {
1087                        table class="table table-sm align-middle" {
1088                            thead {
1089                                tr {
1090                                    th { "Remote PubKey" }
1091                                    th { "Alias" }
1092                                    th { "Funding OutPoint" }
1093                                    th { "Size (sats)" }
1094                                    th { "Active" }
1095                                    th { "Liquidity" }
1096                                    th { "" }
1097                                }
1098                            }
1099                            tbody {
1100                                @for ch in channels {
1101                                    @let row_id = format!("close-form-{}", ch.remote_pubkey);
1102                                    // precompute safely (no @let inline arithmetic)
1103                                    @let size = ch.channel_size_sats.max(1);
1104                                    @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
1105                                    @let inbound_pct  = (ch.inbound_liquidity_sats  as f64 / size as f64) * 100.0;
1106                                    @let funding_outpoint = if let Some(funding_outpoint) = ch.funding_outpoint {
1107                                        funding_outpoint.to_string()
1108                                    } else {
1109                                        "".to_string()
1110                                    };
1111                                    // Abbreviated versions for display (8 chars each side)
1112                                    @let pubkey_str = ch.remote_pubkey.to_string();
1113                                    @let pubkey_abbrev = format!("{}...{}", &pubkey_str[..8], &pubkey_str[pubkey_str.len()-8..]);
1114                                    @let funding_abbrev = if funding_outpoint.len() > 20 {
1115                                        format!("{}...{}", &funding_outpoint[..8], &funding_outpoint[funding_outpoint.len()-8..])
1116                                    } else {
1117                                        funding_outpoint.clone()
1118                                    };
1119
1120                                    tr {
1121                                        td {
1122                                            span
1123                                                class="text-abbrev-copy"
1124                                                title=(format!("{} (click to copy)", pubkey_str))
1125                                                data-original=(pubkey_abbrev)
1126                                                onclick=(format!("navigator.clipboard.writeText('{}').then(() => {{ const el = this; el.textContent = 'Copied!'; setTimeout(() => el.textContent = el.dataset.original, 1000); }});", pubkey_str))
1127                                            {
1128                                                (pubkey_abbrev)
1129                                            }
1130                                        }
1131                                        td {
1132                                            @if let Some(alias) = &ch.remote_node_alias {
1133                                                (alias)
1134                                            } @else {
1135                                                span class="text-muted" { "-" }
1136                                            }
1137                                        }
1138                                        td {
1139                                            @if !funding_outpoint.is_empty() {
1140                                                span
1141                                                    class="text-abbrev-copy"
1142                                                    title=(format!("{} (click to copy)", funding_outpoint))
1143                                                    data-original=(funding_abbrev)
1144                                                    onclick=(format!("navigator.clipboard.writeText('{}').then(() => {{ const el = this; el.textContent = 'Copied!'; setTimeout(() => el.textContent = el.dataset.original, 1000); }});", funding_outpoint))
1145                                                {
1146                                                    (funding_abbrev)
1147                                                }
1148                                            }
1149                                        }
1150                                        td { (ch.channel_size_sats) }
1151                                        td {
1152                                            @if ch.is_active {
1153                                                span class="badge bg-success" { "active" }
1154                                            } @else {
1155                                                span class="badge bg-secondary" { "inactive" }
1156                                            }
1157                                        }
1158
1159                                        // Liquidity bar: single horizontal bar split by two divs
1160                                        td {
1161                                            div style="width:240px;" {
1162                                                div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
1163                                                    div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
1164                                                    div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
1165                                                }
1166
1167                                                div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
1168                                                    span {
1169                                                        span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
1170                                                        (format!("Outbound ({})", ch.outbound_liquidity_sats))
1171                                                    }
1172                                                    span {
1173                                                        span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
1174                                                        (format!("Inbound ({})", ch.inbound_liquidity_sats))
1175                                                    }
1176                                                }
1177                                            }
1178                                        }
1179
1180                                        td style="width: 70px" {
1181                                            // X button toggles a per-row collapse
1182                                            button class="btn btn-sm btn-outline-danger"
1183                                                type="button"
1184                                                data-bs-toggle="collapse"
1185                                                data-bs-target=(format!("#{row_id}"))
1186                                                aria-expanded="false"
1187                                                aria-controls=(row_id)
1188                                            { "X" }
1189                                        }
1190                                    }
1191
1192                                    tr class="collapse" id=(row_id) {
1193                                        td colspan="7" {
1194                                            div class="card card-body" {
1195                                                form
1196                                                    hx-post=(CLOSE_CHANNEL_ROUTE)
1197                                                    hx-target="#channels-container"
1198                                                    hx-swap="outerHTML"
1199                                                    hx-indicator=(format!("#close-spinner-{}", ch.remote_pubkey))
1200                                                    hx-disabled-elt="button[type='submit']"
1201                                                {
1202                                                    // always required
1203                                                    input type="hidden"
1204                                                        name="pubkey"
1205                                                        value=(ch.remote_pubkey.to_string()) {}
1206
1207                                                    div class="form-check mb-3" {
1208                                                        input class="form-check-input"
1209                                                            type="checkbox"
1210                                                            name="force"
1211                                                            value="true"
1212                                                            id=(format!("force-{}", ch.remote_pubkey))
1213                                                            onchange=(format!(
1214                                                                "const input = document.getElementById('sats-vb-{}'); \
1215                                                                input.disabled = this.checked;",
1216                                                                ch.remote_pubkey
1217                                                            )) {}
1218                                                        label class="form-check-label"
1219                                                            for=(format!("force-{}", ch.remote_pubkey)) {
1220                                                            "Force Close"
1221                                                        }
1222                                                    }
1223
1224                                                    // -------------------------------------------
1225                                                    // CONDITIONAL sats/vbyte input
1226                                                    // -------------------------------------------
1227                                                    @if is_lnd {
1228                                                        div class="mb-3" id=(format!("sats-vb-div-{}", ch.remote_pubkey)) {
1229                                                            label class="form-label" for=(format!("sats-vb-{}", ch.remote_pubkey)) {
1230                                                                "Sats per vbyte"
1231                                                            }
1232                                                            input
1233                                                                type="number"
1234                                                                min="1"
1235                                                                step="1"
1236                                                                class="form-control"
1237                                                                id=(format!("sats-vb-{}", ch.remote_pubkey))
1238                                                                name="sats_per_vbyte"
1239                                                                required
1240                                                                placeholder="Enter fee rate" {}
1241
1242                                                            small class="text-muted" {
1243                                                                "Required for LND fee estimation"
1244                                                            }
1245                                                        }
1246                                                    } @else {
1247                                                        // LDK → auto-filled, hidden
1248                                                        input type="hidden"
1249                                                            name="sats_per_vbyte"
1250                                                            value="1" {}
1251                                                    }
1252
1253                                                    // spinner for this specific channel
1254                                                    div class="htmx-indicator mt-2"
1255                                                        id=(format!("close-spinner-{}", ch.remote_pubkey)) {
1256                                                        div class="spinner-border spinner-border-sm text-danger" role="status" {}
1257                                                        span { " Closing..." }
1258                                                    }
1259
1260                                                    button type="submit"
1261                                                        class="btn btn-danger btn-sm" {
1262                                                        "Confirm Close"
1263                                                    }
1264                                                }
1265                                            }
1266                                        }
1267                                    }
1268                                }
1269                            }
1270                        }
1271                        }
1272                    }
1273
1274                    div class="mt-3" {
1275                        // Toggle button
1276                        button id="open-channel-btn" class="btn btn-sm btn-primary"
1277                            type="button"
1278                            data-bs-toggle="collapse"
1279                            data-bs-target="#open-channel-form"
1280                            aria-expanded="false"
1281                            aria-controls="open-channel-form"
1282                        { "Open Channel" }
1283
1284                        // Collapsible form
1285                        div id="open-channel-form" class="collapse mt-3" {
1286                            form hx-post=(OPEN_CHANNEL_ROUTE)
1287                                hx-target="#channels-container"
1288                                hx-swap="outerHTML"
1289                                class="card card-body" {
1290
1291                                h5 class="card-title" { "Open New Channel" }
1292
1293                                div class="mb-2" {
1294                                    label class="form-label" { "Remote Node Public Key" }
1295                                    input type="text" name="pubkey" class="form-control" placeholder="03abcd..." required {}
1296                                }
1297
1298                                div class="mb-2" {
1299                                    label class="form-label" { "Host" }
1300                                    input type="text" name="host" class="form-control" placeholder="1.2.3.4:9735" required {}
1301                                }
1302
1303                                div class="mb-2" {
1304                                    label class="form-label" { "Channel Size (sats)" }
1305                                    input type="number" name="channel_size_sats" class="form-control" placeholder="1000000" required {}
1306                                }
1307
1308                                input type="hidden" name="push_amount_sats" value="0" {}
1309
1310                                button type="submit" class="btn btn-success" { "Confirm Open" }
1311                            }
1312                        }
1313                    }
1314                }
1315            }
1316        }
1317    }
1318}
1319
1320pub async fn channels_fragment_handler<E>(
1321    State(state): State<UiState<DynGatewayApi<E>>>,
1322    _auth: UserAuth,
1323) -> Html<String>
1324where
1325    E: std::fmt::Display,
1326{
1327    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1328    let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
1329
1330    let markup = channels_fragment_markup(channels_result, None, None, is_lnd);
1331    Html(markup.into_string())
1332}
1333
1334pub async fn open_channel_handler<E: Display + Send + Sync>(
1335    State(state): State<UiState<DynGatewayApi<E>>>,
1336    _auth: UserAuth,
1337    Form(payload): Form<OpenChannelRequest>,
1338) -> Html<String> {
1339    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1340    match state.api.handle_open_channel_msg(payload).await {
1341        Ok(txid) => {
1342            let channels_result = state.api.handle_list_channels_msg().await;
1343            let markup = channels_fragment_markup(
1344                channels_result,
1345                Some(format!("Successfully initiated channel open. TxId: {txid}")),
1346                None,
1347                is_lnd,
1348            );
1349            Html(markup.into_string())
1350        }
1351        Err(err) => {
1352            let channels_result = state.api.handle_list_channels_msg().await;
1353            let markup =
1354                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1355            Html(markup.into_string())
1356        }
1357    }
1358}
1359
1360pub async fn close_channel_handler<E: Display + Send + Sync>(
1361    State(state): State<UiState<DynGatewayApi<E>>>,
1362    _auth: UserAuth,
1363    Form(payload): Form<CloseChannelsWithPeerRequest>,
1364) -> Html<String> {
1365    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1366    match state.api.handle_close_channels_with_peer_msg(payload).await {
1367        Ok(_) => {
1368            let channels_result = state.api.handle_list_channels_msg().await;
1369            let markup = channels_fragment_markup(
1370                channels_result,
1371                Some("Successfully initiated channel close".to_string()),
1372                None,
1373                is_lnd,
1374            );
1375            Html(markup.into_string())
1376        }
1377        Err(err) => {
1378            let channels_result = state.api.handle_list_channels_msg().await;
1379            let markup =
1380                channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1381            Html(markup.into_string())
1382        }
1383    }
1384}
1385
1386pub async fn send_onchain_handler<E: Display + Send + Sync>(
1387    State(state): State<UiState<DynGatewayApi<E>>>,
1388    _auth: UserAuth,
1389    Form(payload): Form<SendOnchainRequest>,
1390) -> Html<String> {
1391    let result = state.api.handle_send_onchain_msg(payload).await;
1392
1393    let balances = state.api.handle_get_balances_msg().await;
1394
1395    let markup = match result {
1396        Ok(txid) => wallet_fragment_markup(
1397            &balances,
1398            Some(format!("Send transaction. TxId: {txid}")),
1399            None,
1400        ),
1401        Err(err) => wallet_fragment_markup(&balances, None, Some(err.to_string())),
1402    };
1403
1404    Html(markup.into_string())
1405}
1406
1407pub async fn wallet_fragment_handler<E>(
1408    State(state): State<UiState<DynGatewayApi<E>>>,
1409    _auth: UserAuth,
1410) -> Html<String>
1411where
1412    E: std::fmt::Display,
1413{
1414    let balances_result = state.api.handle_get_balances_msg().await;
1415    let markup = wallet_fragment_markup(&balances_result, None, None);
1416    Html(markup.into_string())
1417}
1418
1419pub async fn generate_receive_address_handler<E>(
1420    State(state): State<UiState<DynGatewayApi<E>>>,
1421    _auth: UserAuth,
1422) -> Html<String>
1423where
1424    E: std::fmt::Display,
1425{
1426    let address_result = state.api.handle_get_ln_onchain_address_msg().await;
1427
1428    let markup = match address_result {
1429        Ok(address) => {
1430            // Generate QR code SVG
1431            let code =
1432                QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
1433            let qr_svg = code.render::<svg::Color>().build();
1434
1435            html! {
1436                div class="card card-body bg-light d-flex flex-column align-items-center" {
1437                    span class="fw-bold mb-3" { "Deposit Address:" }
1438
1439                    // Flex container: address on left, QR on right
1440                    div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
1441
1442                        // Copyable input + text
1443                        div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
1444                            input type="text"
1445                                readonly
1446                                class="form-control mb-2"
1447                                style="text-align:left; font-family: monospace; font-size:1rem;"
1448                                value=(address)
1449                                onclick="copyToClipboard(this)"
1450                            {}
1451                            small class="text-muted" { "Click to copy" }
1452                        }
1453
1454                        // QR code
1455                        div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
1456                            style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
1457                        {
1458                            (PreEscaped(format!(
1459                                r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
1460                                qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
1461                            )))
1462                        }
1463                    }
1464                }
1465            }
1466        }
1467        Err(err) => {
1468            html! {
1469                div class="alert alert-danger" { "Failed to generate address: " (err) }
1470            }
1471        }
1472    };
1473
1474    Html(markup.into_string())
1475}
1476
1477pub async fn payments_fragment_handler<E>(
1478    State(state): State<UiState<DynGatewayApi<E>>>,
1479    _auth: UserAuth,
1480) -> Html<String>
1481where
1482    E: std::fmt::Display,
1483{
1484    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1485    let balances_result = state.api.handle_get_balances_msg().await;
1486    let markup = payments_fragment_markup(&balances_result, None, None, None, is_lnd);
1487    Html(markup.into_string())
1488}
1489
1490pub async fn create_bolt11_invoice_handler<E>(
1491    State(state): State<UiState<DynGatewayApi<E>>>,
1492    _auth: UserAuth,
1493    Form(payload): Form<CreateInvoiceForOperatorPayload>,
1494) -> Html<String>
1495where
1496    E: std::fmt::Display,
1497{
1498    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1499    let invoice_result = state
1500        .api
1501        .handle_create_invoice_for_operator_msg(payload)
1502        .await;
1503    let balances_result = state.api.handle_get_balances_msg().await;
1504
1505    match invoice_result {
1506        Ok(invoice) => {
1507            let results = ReceiveResults {
1508                bolt11_invoice: Some(invoice.to_string()),
1509                bolt12_supported: !is_lnd,
1510                ..Default::default()
1511            };
1512            let markup =
1513                payments_fragment_markup(&balances_result, Some(&results), None, None, is_lnd);
1514            Html(markup.into_string())
1515        }
1516        Err(e) => {
1517            let markup = payments_fragment_markup(
1518                &balances_result,
1519                None,
1520                None,
1521                Some(format!("Failed to create invoice: {e}")),
1522                is_lnd,
1523            );
1524            Html(markup.into_string())
1525        }
1526    }
1527}
1528
1529pub async fn create_receive_invoice_handler<E>(
1530    State(state): State<UiState<DynGatewayApi<E>>>,
1531    _auth: UserAuth,
1532    Form(payload): Form<CreateReceiveInvoicePayload>,
1533) -> Html<String>
1534where
1535    E: std::fmt::Display,
1536{
1537    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1538    let has_amount = payload.amount_msats.is_some() && payload.amount_msats != Some(0);
1539
1540    let mut results = ReceiveResults {
1541        bolt12_supported: !is_lnd,
1542        ..Default::default()
1543    };
1544
1545    // Validate: LND requires amount
1546    if is_lnd && !has_amount {
1547        let balances_result = state.api.handle_get_balances_msg().await;
1548        let markup = payments_fragment_markup(
1549            &balances_result,
1550            None,
1551            None,
1552            Some("Amount is required when using LND (BOLT12 not supported)".to_string()),
1553            is_lnd,
1554        );
1555        return Html(markup.into_string());
1556    }
1557
1558    // Generate BOLT11 if amount is provided
1559    if let Some(amount_msats) = payload.amount_msats {
1560        if amount_msats > 0 {
1561            let bolt11_payload = CreateInvoiceForOperatorPayload {
1562                amount_msats,
1563                expiry_secs: None,
1564                description: payload.description.clone(),
1565            };
1566
1567            match state
1568                .api
1569                .handle_create_invoice_for_operator_msg(bolt11_payload)
1570                .await
1571            {
1572                Ok(invoice) => {
1573                    results.bolt11_invoice = Some(invoice.to_string());
1574                }
1575                Err(e) => {
1576                    results.bolt11_error = Some(format!("Failed to create BOLT11: {e}"));
1577                }
1578            }
1579        }
1580    } else {
1581        results.bolt11_error = Some("Amount required for BOLT11 invoice".to_string());
1582    }
1583
1584    // Generate BOLT12 offer if not LND
1585    if !is_lnd {
1586        let bolt12_payload = CreateOfferPayload {
1587            amount: payload.amount_msats.and_then(|a| {
1588                if a > 0 {
1589                    Some(fedimint_core::Amount::from_msats(a))
1590                } else {
1591                    None
1592                }
1593            }),
1594            description: payload.description,
1595            expiry_secs: None,
1596            quantity: None,
1597        };
1598
1599        match state
1600            .api
1601            .handle_create_offer_for_operator_msg(bolt12_payload)
1602            .await
1603        {
1604            Ok(response) => {
1605                results.bolt12_offer = Some(response.offer);
1606            }
1607            Err(e) => {
1608                results.bolt12_error = Some(e.to_string());
1609            }
1610        }
1611    }
1612
1613    let balances_result = state.api.handle_get_balances_msg().await;
1614    let markup = payments_fragment_markup(&balances_result, Some(&results), None, None, is_lnd);
1615    Html(markup.into_string())
1616}
1617
1618pub async fn pay_bolt11_invoice_handler<E>(
1619    State(state): State<UiState<DynGatewayApi<E>>>,
1620    _auth: UserAuth,
1621    Form(payload): Form<PayInvoiceForOperatorPayload>,
1622) -> Html<String>
1623where
1624    E: std::fmt::Display,
1625{
1626    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1627    let send_result = state.api.handle_pay_invoice_for_operator_msg(payload).await;
1628    let balances_result = state.api.handle_get_balances_msg().await;
1629
1630    match send_result {
1631        Ok(preimage) => {
1632            let markup = payments_fragment_markup(
1633                &balances_result,
1634                None,
1635                Some(format!("Successfully paid invoice. Preimage: {preimage}")),
1636                None,
1637                is_lnd,
1638            );
1639            Html(markup.into_string())
1640        }
1641        Err(e) => {
1642            let markup = payments_fragment_markup(
1643                &balances_result,
1644                None,
1645                None,
1646                Some(format!("Failed to pay invoice: {e}")),
1647                is_lnd,
1648            );
1649            Html(markup.into_string())
1650        }
1651    }
1652}
1653
1654/// Handler to detect payment type and return appropriate form fields
1655pub async fn detect_payment_type_handler<E>(
1656    State(state): State<UiState<DynGatewayApi<E>>>,
1657    _auth: UserAuth,
1658    Form(payload): Form<DetectPaymentTypePayload>,
1659) -> Html<String>
1660where
1661    E: std::fmt::Display,
1662{
1663    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1664    let payment_type = detect_payment_type(&payload.payment_string);
1665
1666    let markup = match payment_type {
1667        PaymentStringType::Bolt12 if is_lnd => {
1668            // LND cannot pay BOLT12 - show error
1669            html! {
1670                div class="alert alert-danger mt-2" {
1671                    strong { "BOLT12 offers are not supported with LND." }
1672                    p class="mb-0 mt-1" { "Please use a BOLT11 invoice instead." }
1673                }
1674                script {
1675                    (PreEscaped("document.getElementById('send-submit-btn').disabled = true;"))
1676                }
1677            }
1678        }
1679        PaymentStringType::Bolt12 => {
1680            // Show optional BOLT12 fields
1681            let offer = Offer::from_str(&payload.payment_string);
1682
1683            if let Ok(offer) = offer {
1684                html! {
1685                    div class="mt-3 p-2 bg-light rounded" {
1686
1687                        @match offer.amount() {
1688                            Some(Amount::Bitcoin { amount_msats }) => {
1689                                div class="mb-2" {
1690                                    label class="form-label" for="amount_msats" {
1691                                        "Amount (msats)"
1692                                        small class="text-muted ms-2" { "(fixed by offer)" }
1693                                    }
1694
1695                                    input
1696                                        type="number"
1697                                        class="form-control"
1698                                        id="amount_msats"
1699                                        name="amount_msats"
1700                                        value=(amount_msats)
1701                                        readonly
1702                                        ;
1703                                }
1704                            }
1705                            Some(_) => {
1706                                div class="alert alert-danger mb-2" {
1707                                    strong { "Unsupported offer currency." }
1708                                    " Only Bitcoin-denominated BOLT12 offers are supported."
1709                                }
1710                            }
1711                            None => {
1712                                div class="mb-2" {
1713                                    label class="form-label" for="amount_msats" {
1714                                        "Amount (msats)"
1715                                        small class="text-muted ms-2" { "(required)" }
1716                                    }
1717
1718                                    input
1719                                        type="number"
1720                                        class="form-control"
1721                                        id="amount_msats"
1722                                        name="amount_msats"
1723                                        min="1"
1724                                        placeholder="Enter amount in msats"
1725                                        ;
1726                                }
1727                            }
1728                        }
1729
1730                        // Only show payer note if we didn't hit an error
1731                        @if matches!(offer.amount(), Some(Amount::Bitcoin { .. }) | None) {
1732                            div class="mb-2" {
1733                                label class="form-label" for="payer_note" {
1734                                    "Payer Note"
1735                                    small class="text-muted ms-2" { "(optional)" }
1736                                }
1737                                input
1738                                    type="text"
1739                                    class="form-control"
1740                                    id="payer_note"
1741                                    name="payer_note"
1742                                    placeholder="Optional note to recipient"
1743                                    ;
1744                            }
1745                        }
1746                    }
1747
1748                    // Enable submit only if the offer is usable
1749                    @if matches!(offer.amount(), Some(Amount::Bitcoin { .. }) | None) {
1750                        script {
1751                            (PreEscaped(
1752                                "document.getElementById('send-submit-btn').disabled = false;"
1753                            ))
1754                        }
1755                    }
1756                }
1757            } else {
1758                html! {
1759                    div class="alert alert-warning mt-2" {
1760                        small { "Invalid BOLT12 Offer" }
1761                    }
1762                }
1763            }
1764        }
1765        PaymentStringType::Bolt11 => {
1766            // Clear any previous BOLT12 fields, enable submit
1767            let bolt11 = Bolt11Invoice::from_str(&payload.payment_string);
1768            if let Ok(bolt11) = bolt11 {
1769                let amount = bolt11.amount_milli_satoshis();
1770                let payee_pub_key = bolt11.payee_pub_key();
1771                let payment_hash = bolt11.payment_hash();
1772                let expires_at = bolt11.expires_at();
1773
1774                html! {
1775                    div class="mt-3 p-2 bg-light rounded" {
1776                        div class="mb-2" {
1777                            strong { "Amount: " }
1778                            @match amount {
1779                                Some(msats) => {
1780                                    span { (format!("{msats} msats")) }
1781                                }
1782                                None => {
1783                                    span class="text-muted" { "Amount not specified" }
1784                                }
1785                            }
1786                        }
1787
1788                        div class="mb-2" {
1789                            strong { "Payee Public Key: " }
1790                            @match payee_pub_key {
1791                                Some(pk) => {
1792                                    code { (pk.to_string()) }
1793                                }
1794                                None => {
1795                                    span class="text-muted" { "Not provided" }
1796                                }
1797                            }
1798                        }
1799
1800                        div class="mb-2" {
1801                            strong { "Payment Hash: " }
1802                            code { (payment_hash.to_string()) }
1803                        }
1804
1805                        div class="mb-2" {
1806                            strong { "Expires At: " }
1807                            @match expires_at {
1808                                Some(unix_ts) => {
1809                                    @let datetime: DateTime<Utc> =
1810                                        DateTime::<Utc>::from(UNIX_EPOCH + unix_ts);
1811                                    span {
1812                                        (datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string())
1813                                    }
1814                                }
1815                                None => {
1816                                    span class="text-muted" { "No expiry" }
1817                                }
1818                            }
1819                        }
1820                    }
1821
1822                    script {
1823                        (PreEscaped("document.getElementById('send-submit-btn').disabled = false;"))
1824                    }
1825                }
1826            } else {
1827                html! {
1828                    div class="alert alert-warning mt-2" {
1829                        small { "Invalid BOLT11 Invoice" }
1830                    }
1831                }
1832            }
1833        }
1834        PaymentStringType::Unknown => {
1835            // Show validation hint if there's some input
1836            if payload.payment_string.trim().is_empty() {
1837                html! {}
1838            } else {
1839                html! {
1840                    div class="alert alert-warning mt-2" {
1841                        small { "Could not detect payment type. Please paste a valid BOLT11 invoice (starting with lnbc/lntb/lnbcrt) or BOLT12 offer (starting with lno)." }
1842                    }
1843                }
1844            }
1845        }
1846    };
1847
1848    Html(markup.into_string())
1849}
1850
1851/// Unified handler for paying BOLT11 invoices or BOLT12 offers
1852pub async fn pay_unified_handler<E>(
1853    State(state): State<UiState<DynGatewayApi<E>>>,
1854    _auth: UserAuth,
1855    Form(payload): Form<UnifiedSendPayload>,
1856) -> Html<String>
1857where
1858    E: std::fmt::Display,
1859{
1860    let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1861    let payment_type = detect_payment_type(&payload.payment_string);
1862    let balances_result = state.api.handle_get_balances_msg().await;
1863
1864    match payment_type {
1865        PaymentStringType::Bolt12 if is_lnd => {
1866            // Should not happen if detection works, but handle gracefully
1867            let markup = payments_fragment_markup(
1868                &balances_result,
1869                None,
1870                None,
1871                Some(
1872                    "BOLT12 offers are not supported with LND. Please use a BOLT11 invoice."
1873                        .to_string(),
1874                ),
1875                is_lnd,
1876            );
1877            Html(markup.into_string())
1878        }
1879        PaymentStringType::Bolt12 => {
1880            // Pay BOLT12 offer
1881            let offer_payload = PayOfferPayload {
1882                offer: payload.payment_string,
1883                amount: payload.amount_msats.map(fedimint_core::Amount::from_msats),
1884                quantity: None,
1885                payer_note: payload.payer_note,
1886            };
1887
1888            match state
1889                .api
1890                .handle_pay_offer_for_operator_msg(offer_payload)
1891                .await
1892            {
1893                Ok(response) => {
1894                    let markup = payments_fragment_markup(
1895                        &balances_result,
1896                        None,
1897                        Some(format!(
1898                            "Successfully paid BOLT12 offer. Preimage: {}",
1899                            response.preimage
1900                        )),
1901                        None,
1902                        is_lnd,
1903                    );
1904                    Html(markup.into_string())
1905                }
1906                Err(e) => {
1907                    let markup = payments_fragment_markup(
1908                        &balances_result,
1909                        None,
1910                        None,
1911                        Some(format!("Failed to pay BOLT12 offer: {e}")),
1912                        is_lnd,
1913                    );
1914                    Html(markup.into_string())
1915                }
1916            }
1917        }
1918        PaymentStringType::Bolt11 => {
1919            // Parse and pay BOLT11 invoice
1920            match payload
1921                .payment_string
1922                .trim()
1923                .parse::<lightning_invoice::Bolt11Invoice>()
1924            {
1925                Ok(invoice) => {
1926                    let bolt11_payload = PayInvoiceForOperatorPayload { invoice };
1927                    match state
1928                        .api
1929                        .handle_pay_invoice_for_operator_msg(bolt11_payload)
1930                        .await
1931                    {
1932                        Ok(preimage) => {
1933                            let markup = payments_fragment_markup(
1934                                &balances_result,
1935                                None,
1936                                Some(format!("Successfully paid invoice. Preimage: {preimage}")),
1937                                None,
1938                                is_lnd,
1939                            );
1940                            Html(markup.into_string())
1941                        }
1942                        Err(e) => {
1943                            let markup = payments_fragment_markup(
1944                                &balances_result,
1945                                None,
1946                                None,
1947                                Some(format!("Failed to pay invoice: {e}")),
1948                                is_lnd,
1949                            );
1950                            Html(markup.into_string())
1951                        }
1952                    }
1953                }
1954                Err(e) => {
1955                    let markup = payments_fragment_markup(
1956                        &balances_result,
1957                        None,
1958                        None,
1959                        Some(format!("Invalid BOLT11 invoice: {e}")),
1960                        is_lnd,
1961                    );
1962                    Html(markup.into_string())
1963                }
1964            }
1965        }
1966        PaymentStringType::Unknown => {
1967            let markup = payments_fragment_markup(
1968                &balances_result,
1969                None,
1970                None,
1971                Some("Could not detect payment type. Please provide a valid BOLT11 invoice or BOLT12 offer.".to_string()),
1972                is_lnd,
1973            );
1974            Html(markup.into_string())
1975        }
1976    }
1977}
1978
1979pub async fn transactions_fragment_handler<E>(
1980    State(state): State<UiState<DynGatewayApi<E>>>,
1981    _auth: UserAuth,
1982    Query(params): Query<HashMap<String, String>>,
1983) -> Html<String>
1984where
1985    E: std::fmt::Display + std::fmt::Debug,
1986{
1987    let now = fedimint_core::time::now();
1988    let end_secs = now
1989        .duration_since(std::time::UNIX_EPOCH)
1990        .expect("Time went backwards")
1991        .as_secs();
1992
1993    let start_secs = now
1994        .checked_sub(std::time::Duration::from_secs(60 * 60 * 24))
1995        .unwrap_or(now)
1996        .duration_since(std::time::UNIX_EPOCH)
1997        .expect("Time went backwards")
1998        .as_secs();
1999
2000    let parse = |key: &str| -> Option<u64> {
2001        params.get(key).and_then(|s| {
2002            chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
2003                .ok()
2004                .map(|dt| {
2005                    let dt_utc: chrono::DateTime<Utc> = Utc.from_utc_datetime(&dt);
2006                    dt_utc.timestamp() as u64
2007                })
2008        })
2009    };
2010
2011    let start_secs = parse("start_secs").unwrap_or(start_secs);
2012    let end_secs = parse("end_secs").unwrap_or(end_secs);
2013
2014    let transactions_result = state
2015        .api
2016        .handle_list_transactions_msg(ListTransactionsPayload {
2017            start_secs,
2018            end_secs,
2019        })
2020        .await;
2021
2022    Html(transactions_fragment_markup(&transactions_result, start_secs, end_secs).into_string())
2023}