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