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