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