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