1use std::collections::HashMap;
2use std::fmt::Display;
3use std::time::{Duration, UNIX_EPOCH};
4
5use axum::Form;
6use axum::extract::{Query, State};
7use axum::response::Html;
8use chrono::offset::LocalResult;
9use chrono::{TimeZone, Utc};
10use fedimint_core::bitcoin::Network;
11use fedimint_core::time::now;
12use fedimint_gateway_common::{
13 ChannelInfo, CloseChannelsWithPeerRequest, CreateInvoiceForOperatorPayload, GatewayBalances,
14 GatewayInfo, LightningInfo, LightningMode, ListTransactionsPayload, ListTransactionsResponse,
15 OpenChannelRequest, PayInvoiceForOperatorPayload, PaymentStatus, SendOnchainRequest,
16};
17use fedimint_ui_common::UiState;
18use fedimint_ui_common::auth::UserAuth;
19use maud::{Markup, PreEscaped, html};
20use qrcode::QrCode;
21use qrcode::render::svg;
22
23use crate::{
24 CHANNEL_FRAGMENT_ROUTE, CLOSE_CHANNEL_ROUTE, CREATE_BOLT11_INVOICE_ROUTE, DynGatewayApi,
25 LN_ONCHAIN_ADDRESS_ROUTE, OPEN_CHANNEL_ROUTE, PAY_BOLT11_INVOICE_ROUTE,
26 PAYMENTS_FRAGMENT_ROUTE, SEND_ONCHAIN_ROUTE, TRANSACTIONS_FRAGMENT_ROUTE,
27 WALLET_FRAGMENT_ROUTE,
28};
29
30pub async fn render<E>(gateway_info: &GatewayInfo, api: &DynGatewayApi<E>) -> Markup
31where
32 E: std::fmt::Display,
33{
34 let channels_result = api.handle_list_channels_msg().await;
36
37 let (block_height, status_badge, network, alias, pubkey) = match &gateway_info.lightning_info {
39 LightningInfo::Connected {
40 network,
41 block_height,
42 synced_to_chain,
43 alias,
44 public_key,
45 } => {
46 let badge = if *synced_to_chain {
47 html! { span class="badge bg-success" { "🟢 Synced" } }
48 } else {
49 html! { span class="badge bg-warning" { "🟡 Syncing" } }
50 };
51 (
52 *block_height,
53 badge,
54 *network,
55 Some(alias.clone()),
56 Some(*public_key),
57 )
58 }
59 LightningInfo::NotConnected => (
60 0,
61 html! { span class="badge bg-danger" { "❌ Not Connected" } },
62 Network::Bitcoin,
63 None,
64 None,
65 ),
66 };
67
68 let is_lnd = matches!(api.lightning_mode(), LightningMode::Lnd { .. });
69 let balances_result = api.handle_get_balances_msg().await;
70 let now = now();
71 let start = now
72 .checked_sub(Duration::from_secs(60 * 60 * 24))
73 .expect("Cannot be negative");
74 let start_secs = start
75 .duration_since(UNIX_EPOCH)
76 .expect("Cannot be before epoch")
77 .as_secs();
78 let end = now;
79 let end_secs = end
80 .duration_since(UNIX_EPOCH)
81 .expect("Cannot be before epoch")
82 .as_secs();
83 let transactions_result = api
84 .handle_list_transactions_msg(ListTransactionsPayload {
85 start_secs,
86 end_secs,
87 })
88 .await;
89
90 html! {
91 script {
92 (PreEscaped(r#"
93 function copyToClipboard(input) {
94 input.select();
95 document.execCommand('copy');
96 const hint = input.nextElementSibling;
97 hint.textContent = 'Copied!';
98 setTimeout(() => hint.textContent = 'Click to copy', 2000);
99 }
100 "#))
101 }
102
103 div class="card h-100" {
104 div class="card-header dashboard-header" { "Lightning Node" }
105 div class="card-body" {
106
107 ul class="nav nav-tabs" id="lightningTabs" role="tablist" {
109 li class="nav-item" role="presentation" {
110 button class="nav-link active"
111 id="connection-tab"
112 data-bs-toggle="tab"
113 data-bs-target="#connection-tab-pane"
114 type="button"
115 role="tab"
116 { "Connection Info" }
117 }
118 li class="nav-item" role="presentation" {
119 button class="nav-link"
120 id="wallet-tab"
121 data-bs-toggle="tab"
122 data-bs-target="#wallet-tab-pane"
123 type="button"
124 role="tab"
125 { "Wallet" }
126 }
127 li class="nav-item" role="presentation" {
128 button class="nav-link"
129 id="channels-tab"
130 data-bs-toggle="tab"
131 data-bs-target="#channels-tab-pane"
132 type="button"
133 role="tab"
134 { "Channels" }
135 }
136 li class="nav-item" role="presentation" {
137 button class="nav-link"
138 id="payments-tab"
139 data-bs-toggle="tab"
140 data-bs-target="#payments-tab-pane"
141 type="button"
142 role="tab"
143 { "Payments" }
144 }
145 li class="nav-item" role="presentation" {
146 button class="nav-link"
147 id="transactions-tab"
148 data-bs-toggle="tab"
149 data-bs-target="#transactions-tab-pane"
150 type="button"
151 role="tab"
152 { "Transactions" }
153 }
154 }
155
156 div class="tab-content mt-3" id="lightningTabsContent" {
157
158 div class="tab-pane fade show active"
162 id="connection-tab-pane"
163 role="tabpanel"
164 aria-labelledby="connection-tab" {
165
166 @match &gateway_info.lightning_mode {
167 LightningMode::Lnd { lnd_rpc_addr, lnd_tls_cert, lnd_macaroon } => {
168 div id="node-type" class="alert alert-info" {
169 "Node Type: " strong { "External LND" }
170 }
171 table class="table table-sm mb-0" {
172 tbody {
173 tr {
174 th { "RPC Address" }
175 td { (lnd_rpc_addr) }
176 }
177 tr {
178 th { "TLS Cert" }
179 td { (lnd_tls_cert) }
180 }
181 tr {
182 th { "Macaroon" }
183 td { (lnd_macaroon) }
184 }
185 tr {
186 th { "Network" }
187 td { (network) }
188 }
189 tr {
190 th { "Block Height" }
191 td { (block_height) }
192 }
193 tr {
194 th { "Status" }
195 td { (status_badge) }
196 }
197 @if let Some(a) = alias {
198 tr {
199 th { "Lightning Alias" }
200 td { (a) }
201 }
202 }
203 @if let Some(pk) = pubkey {
204 tr {
205 th { "Lightning Public Key" }
206 td { (pk) }
207 }
208 }
209 }
210 }
211 }
212 LightningMode::Ldk { lightning_port, .. } => {
213 div id="node-type" class="alert alert-info" {
214 "Node Type: " strong { "Internal LDK" }
215 }
216 table class="table table-sm mb-0" {
217 tbody {
218 tr {
219 th { "Port" }
220 td { (lightning_port) }
221 }
222 tr {
223 th { "Network" }
224 td { (network) }
225 }
226 tr {
227 th { "Block Height" }
228 td { (block_height) }
229 }
230 tr {
231 th { "Status" }
232 td { (status_badge) }
233 }
234 @if let Some(a) = alias {
235 tr {
236 th { "Alias" }
237 td { (a) }
238 }
239 }
240 @if let Some(pk) = pubkey {
241 tr {
242 th { "Public Key" }
243 td { (pk) }
244 }
245 }
246 }
247 }
248 }
249 }
250 }
251
252 div class="tab-pane fade"
256 id="wallet-tab-pane"
257 role="tabpanel"
258 aria-labelledby="wallet-tab" {
259
260 div class="d-flex justify-content-between align-items-center mb-2" {
261 div { strong { "Wallet" } }
262 button class="btn btn-sm btn-outline-secondary"
263 hx-get=(WALLET_FRAGMENT_ROUTE)
264 hx-target="#wallet-container"
265 hx-swap="outerHTML"
266 type="button"
267 { "Refresh" }
268 }
269
270 (wallet_fragment_markup(&balances_result, None, None))
271 }
272
273 div class="tab-pane fade"
277 id="channels-tab-pane"
278 role="tabpanel"
279 aria-labelledby="channels-tab" {
280
281 div class="d-flex justify-content-between align-items-center mb-2" {
282 div { strong { "Channels" } }
283 button class="btn btn-sm btn-outline-secondary"
284 hx-get=(CHANNEL_FRAGMENT_ROUTE)
285 hx-target="#channels-container"
286 hx-swap="outerHTML"
287 type="button"
288 { "Refresh" }
289 }
290
291 (channels_fragment_markup(channels_result, None, None, is_lnd))
292 }
293
294 div class="tab-pane fade"
298 id="payments-tab-pane"
299 role="tabpanel"
300 aria-labelledby="payments-tab" {
301
302 div class="d-flex justify-content-between align-items-center mb-2" {
303 div { strong { "Payments" } }
304 button class="btn btn-sm btn-outline-secondary"
305 hx-get=(PAYMENTS_FRAGMENT_ROUTE)
306 hx-target="#payments-container"
307 hx-swap="outerHTML"
308 type="button"
309 { "Refresh" }
310 }
311
312 (payments_fragment_markup(&balances_result, None, None, None))
313 }
314
315 div class="tab-pane fade"
319 id="transactions-tab-pane"
320 role="tabpanel"
321 aria-labelledby="transactions-tab" {
322
323 (transactions_fragment_markup(&transactions_result, start_secs, end_secs))
324 }
325 }
326 }
327 }
328 }
329}
330
331pub fn transactions_fragment_markup<E>(
332 transactions_result: &Result<ListTransactionsResponse, E>,
333 start_secs: u64,
334 end_secs: u64,
335) -> Markup
336where
337 E: std::fmt::Display,
338{
339 let start_dt = match Utc.timestamp_opt(start_secs as i64, 0) {
341 LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
342 _ => "1970-01-01T00:00:00".to_string(),
343 };
344
345 let end_dt = match Utc.timestamp_opt(end_secs as i64, 0) {
346 LocalResult::Single(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
347 _ => "1970-01-01T00:00:00".to_string(),
348 };
349
350 html!(
351 div id="transactions-container" {
352
353 form class="row g-3 mb-3"
357 hx-get=(TRANSACTIONS_FRAGMENT_ROUTE)
358 hx-target="#transactions-container"
359 hx-swap="outerHTML"
360 {
361 div class="col-auto" {
363 label class="form-label" for="start-secs" { "Start" }
364 input
365 class="form-control"
366 type="datetime-local"
367 id="start-secs"
368 name="start_secs"
369 step="1"
370 value=(start_dt);
371 }
372
373 div class="col-auto" {
375 label class="form-label" for="end-secs" { "End" }
376 input
377 class="form-control"
378 type="datetime-local"
379 id="end-secs"
380 name="end_secs"
381 step="1"
382 value=(end_dt);
383 }
384
385 div class="col-auto align-self-end" {
387 button class="btn btn-outline-secondary" type="submit" { "Refresh" }
388 button class="btn btn-outline-secondary me-2" type="button"
389 id="last-day-btn"
390 { "Last Day" }
391 }
392 }
393
394 script {
395 (PreEscaped(r#"
396 document.getElementById('last-day-btn').addEventListener('click', () => {
397 const now = new Date();
398 const endInput = document.getElementById('end-secs');
399 const startInput = document.getElementById('start-secs');
400
401 const pad = n => n.toString().padStart(2, '0');
402
403 const formatUTC = d =>
404 `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
405
406 endInput.value = formatUTC(now);
407
408 const start = new Date(now.getTime() - 24*60*60*1000); // 24 hours ago UTC
409 startInput.value = formatUTC(start);
410 });
411 "#))
412 }
413
414 @match transactions_result {
418 Err(err) => {
419 div class="alert alert-danger" {
420 "Failed to load lightning transactions: " (err)
421 }
422 }
423 Ok(transactions) => {
424 @if transactions.transactions.is_empty() {
425 div class="alert alert-info mt-3" {
426 "No transactions found in this time range."
427 }
428 } @else {
429 ul class="list-group mt-3" {
430 @for tx in &transactions.transactions {
431 li class="list-group-item p-2 mb-1 transaction-item"
432 style="border-radius: 0.5rem; transition: background-color 0.2s;"
433 {
434 div style="display: flex; justify-content: space-between; align-items: center;" {
436 div {
438 div style="font-weight: bold; font-size: 0.9rem;" {
439 (format!("{:?}", tx.payment_kind))
440 " — "
441 span { (format!("{:?}", tx.direction)) }
442 }
443
444 div style="font-size: 0.75rem; margin-top: 2px;" {
445 @let status_badge = match tx.status {
446 PaymentStatus::Pending => html! { span class="badge bg-warning" { "⏳ Pending" } },
447 PaymentStatus::Succeeded => html! { span class="badge bg-success" { "✅ Succeeded" } },
448 PaymentStatus::Failed => html! { span class="badge bg-danger" { "❌ Failed" } },
449 };
450 (status_badge)
451 }
452 }
453
454 div style="text-align: right;" {
456 div style="font-weight: bold; font-size: 0.9rem;" {
457 (format!("{} sats", tx.amount.msats / 1000))
458 }
459 div style="font-size: 0.7rem; color: #6c757d;" {
460 @let timestamp = match Utc.timestamp_opt(tx.timestamp_secs as i64, 0) {
461 LocalResult::Single(dt) => dt,
462 _ => Utc.timestamp_opt(0, 0).unwrap(),
463 };
464 (timestamp.format("%Y-%m-%d %H:%M:%S").to_string())
465 }
466 }
467 }
468
469 @if let Some(hash) = &tx.payment_hash {
471 div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 2px;" {
472 "Hash: " (hash.to_string())
473 }
474 }
475
476 @if let Some(preimage) = &tx.preimage {
477 div style="font-family: monospace; font-size: 0.7rem; color: #6c757d; margin-top: 1px;" {
478 "Preimage: " (preimage)
479 }
480 }
481
482 script {
484 (PreEscaped(r#"
485 const li = document.currentScript.parentElement;
486 li.addEventListener('mouseenter', () => li.style.backgroundColor = '#f8f9fa');
487 li.addEventListener('mouseleave', () => li.style.backgroundColor = 'white');
488 "#))
489 }
490 }
491 }
492 }
493 }
494 }
495 }
496 }
497 )
498}
499
500pub fn payments_fragment_markup<E>(
501 balances_result: &Result<GatewayBalances, E>,
502 created_invoice: Option<String>,
503 success_msg: Option<String>,
504 error_msg: Option<String>,
505) -> Markup
506where
507 E: std::fmt::Display,
508{
509 html!(
510 div id="payments-container" {
511 @match balances_result {
512 Err(err) => {
513 div class="alert alert-danger" {
515 "Failed to load lightning balance: " (err)
516 }
517 }
518 Ok(bal) => {
519
520 @if let Some(success) = success_msg {
521 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
522 span { (success) }
523 }
524 }
525
526 @if let Some(error) = error_msg {
527 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
528 span { (error) }
529 }
530 }
531
532 div id="lightning-balance-banner"
533 class="alert alert-info d-flex justify-content-between align-items-center" {
534
535 @let lightning_balance = format!("{}", fedimint_core::Amount::from_msats(bal.lightning_balance_msats));
536
537 span {
538 "Lightning Balance: "
539 strong id="lightning-balance" { (lightning_balance) }
540 }
541 }
542
543 div class="mt-3" {
545 button class="btn btn-sm btn-outline-primary me-2"
546 type="button"
547 onclick="
548 document.getElementById('receive-form').classList.add('d-none');
549 document.getElementById('pay-invoice-form').classList.toggle('d-none');
550 "
551 { "Send" }
552
553 button class="btn btn-sm btn-outline-success"
554 type="button"
555 onclick="
556 document.getElementById('pay-invoice-form').classList.add('d-none');
557 document.getElementById('receive-form').classList.toggle('d-none');
558 "
559 { "Receive" }
560 }
561
562 div id="pay-invoice-form" class="card card-body mt-3 d-none" {
564 form
565 id="pay-ln-invoice-form"
566 hx-post=(PAY_BOLT11_INVOICE_ROUTE)
567 hx-target="#payments-container"
568 hw-swap="outerHTML"
569 {
570 div class="mb-3" {
571 label class="form-label" for="invoice" { "Bolt11 Invoice" }
572 input type="text"
573 class="form-control"
574 id="invoice"
575 name="invoice"
576 required;
577 }
578
579 button
580 type="submit"
581 class="btn btn-success btn-sm"
582 { "Pay Invoice" }
583 }
584 }
585
586 div id="receive-form" class={
588 @if created_invoice.is_some() { "card card-body mt-3 d-none" }
589 @else { "card card-body mt-3 d-none" }
590 } {
591 form
592 id="create-ln-invoice-form"
593 hx-post=(CREATE_BOLT11_INVOICE_ROUTE)
594 hx-target="#payments-container"
595 hx-swap="outerHTML"
596 {
597 div class="mb-3" {
598 label class="form-label" for="amount_msats" { "Amount (msats)" }
599 input type="number"
600 class="form-control"
601 id="amount_msats"
602 name="amount_msats"
603 min="1"
604 required;
605 }
606
607 button
608 type="submit"
609 class="btn btn-success btn-sm"
610 { "Create Bolt11 Invoice" }
611 }
612 }
613
614 @if let Some(invoice) = created_invoice {
618
619 @let code =
620 QrCode::new(&invoice).expect("Failed to generate QR code");
621 @let qr_svg = code.render::<svg::Color>().build();
622
623 div class="card card-body mt-4" {
624
625 div class="card card-body bg-light d-flex flex-column align-items-center" {
626 span class="fw-bold mb-3" { "Bolt11 Invoice:" }
627
628 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
630
631 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
633 input type="text"
634 readonly
635 class="form-control mb-2"
636 style="text-align:left; font-family: monospace; font-size:1rem;"
637 value=(invoice)
638 onclick="copyToClipboard(this)"
639 {}
640 small class="text-muted" { "Click to copy" }
641 }
642
643 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
645 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
646 {
647 (PreEscaped(format!(
648 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
649 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
650 )))
651 }
652 }
653 }
654 }
655 }
656 }
657 }
658 }
659 )
660}
661
662pub fn wallet_fragment_markup<E>(
663 balances_result: &Result<GatewayBalances, E>,
664 success_msg: Option<String>,
665 error_msg: Option<String>,
666) -> Markup
667where
668 E: std::fmt::Display,
669{
670 html!(
671 div id="wallet-container" {
672 @match balances_result {
673 Err(err) => {
674 div class="alert alert-danger" {
676 "Failed to load wallet balance: " (err)
677 }
678 }
679 Ok(bal) => {
680
681 @if let Some(success) = success_msg {
682 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
683 span { (success) }
684 }
685 }
686
687 @if let Some(error) = error_msg {
688 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
689 span { (error) }
690 }
691 }
692
693 div id="wallet-balance-banner"
694 class="alert alert-info d-flex justify-content-between align-items-center" {
695
696 @let onchain = format!("{}", bitcoin::Amount::from_sat(bal.onchain_balance_sats));
697
698 span {
699 "Balance: "
700 strong id="wallet-balance" { (onchain) }
701 }
702 }
703
704 div class="mt-3" {
705 button class="btn btn-sm btn-outline-primary me-2"
707 type="button"
708 onclick="
709 document.getElementById('send-form').classList.toggle('d-none');
710 document.getElementById('receive-address-container').innerHTML = '';
711 "
712 { "Send" }
713
714
715 button class="btn btn-sm btn-outline-success"
716 hx-get=(LN_ONCHAIN_ADDRESS_ROUTE)
717 hx-target="#receive-address-container"
718 hx-swap="outerHTML"
719 type="button"
720 onclick="document.getElementById('send-form').classList.add('d-none');"
721 { "Receive" }
722 }
723
724 div id="send-form" class="card card-body mt-3 d-none" {
728
729 form
730 id="send-onchain-form"
731 hx-post=(SEND_ONCHAIN_ROUTE)
732 hx-target="#wallet-container"
733 hx-swap="outerHTML"
734 {
735 div class="mb-3" {
737 label class="form-label" for="address" { "Bitcoin Address" }
738 input
739 type="text"
740 class="form-control"
741 id="address"
742 name="address"
743 required;
744 }
745
746 div class="mb-3" {
748 label class="form-label" for="amount" { "Amount (sats)" }
749 div class="input-group" {
750 input
751 type="text"
752 class="form-control"
753 id="amount"
754 name="amount"
755 placeholder="e.g. 10000 or all"
756 required;
757
758 button
759 class="btn btn-outline-secondary"
760 type="button"
761 onclick="document.getElementById('amount').value = 'all';"
762 { "All" }
763 }
764 }
765
766 div class="mb-3" {
768 label class="form-label" for="fee_rate" { "Sats per vbyte" }
769 input
770 type="number"
771 class="form-control"
772 id="fee_rate"
773 name="fee_rate_sats_per_vbyte"
774 min="1"
775 required;
776 }
777
778 div class="mt-3" {
780 button
781 type="submit"
782 class="btn btn-sm btn-primary"
783 {
784 "Confirm Send"
785 }
786 }
787 }
788 }
789
790 div id="receive-address-container" class="mt-3" {}
791 }
792 }
793 }
794 )
795}
796
797pub fn channels_fragment_markup<E>(
800 channels_result: Result<Vec<ChannelInfo>, E>,
801 success_msg: Option<String>,
802 error_msg: Option<String>,
803 is_lnd: bool,
804) -> Markup
805where
806 E: std::fmt::Display,
807{
808 html! {
809 div id="channels-container" {
811 @match channels_result {
812 Err(err_str) => {
813 div class="alert alert-danger" {
814 "Failed to load channels: " (err_str)
815 }
816 }
817 Ok(channels) => {
818
819 @if let Some(success) = success_msg {
820 div class="alert alert-success mt-2 d-flex justify-content-between align-items-center" {
821 span { (success) }
822 }
823 }
824
825 @if let Some(error) = error_msg {
826 div class="alert alert-danger mt-2 d-flex justify-content-between align-items-center" {
827 span { (error) }
828 }
829 }
830
831 @if channels.is_empty() {
832 div class="alert alert-info" { "No channels found." }
833 } @else {
834 table class="table table-sm align-middle" {
835 thead {
836 tr {
837 th { "Remote PubKey" }
838 th { "Funding OutPoint" }
839 th { "Size (sats)" }
840 th { "Active" }
841 th { "Liquidity" }
842 th { "" }
843 }
844 }
845 tbody {
846 @for ch in channels {
847 @let row_id = format!("close-form-{}", ch.remote_pubkey);
848 @let size = ch.channel_size_sats.max(1);
850 @let outbound_pct = (ch.outbound_liquidity_sats as f64 / size as f64) * 100.0;
851 @let inbound_pct = (ch.inbound_liquidity_sats as f64 / size as f64) * 100.0;
852 @let funding_outpoint = if let Some(funding_outpoint) = ch.funding_outpoint {
853 funding_outpoint.to_string()
854 } else {
855 "".to_string()
856 };
857
858 tr {
859 td { (ch.remote_pubkey.to_string()) }
860 td { (funding_outpoint) }
861 td { (ch.channel_size_sats) }
862 td {
863 @if ch.is_active {
864 span class="badge bg-success" { "active" }
865 } @else {
866 span class="badge bg-secondary" { "inactive" }
867 }
868 }
869
870 td {
872 div style="width:240px;" {
873 div style="display:flex;height:10px;width:100%;border-radius:3px;overflow:hidden" {
874 div style=(format!("background:#28a745;width:{:.2}%;", outbound_pct)) {}
875 div style=(format!("background:#0d6efd;width:{:.2}%;", inbound_pct)) {}
876 }
877
878 div style="font-size:0.75rem;display:flex;justify-content:space-between;margin-top:3px;" {
879 span {
880 span style="display:inline-block;width:10px;height:10px;background:#28a745;margin-right:4px;border-radius:2px;" {}
881 "Outbound"
882 }
883 span {
884 span style="display:inline-block;width:10px;height:10px;background:#0d6efd;margin-right:4px;border-radius:2px;" {}
885 "Inbound"
886 }
887 }
888 }
889 }
890
891 td style="width: 70px" {
892 button class="btn btn-sm btn-outline-danger"
894 type="button"
895 data-bs-toggle="collapse"
896 data-bs-target=(format!("#{row_id}"))
897 aria-expanded="false"
898 aria-controls=(row_id)
899 { "X" }
900 }
901 }
902
903 tr class="collapse" id=(row_id) {
904 td colspan="6" {
905 div class="card card-body" {
906 form
907 hx-post=(CLOSE_CHANNEL_ROUTE)
908 hx-target="#channels-container"
909 hx-swap="outerHTML"
910 hx-indicator=(format!("#close-spinner-{}", ch.remote_pubkey))
911 hx-disabled-elt="button[type='submit']"
912 {
913 input type="hidden"
915 name="pubkey"
916 value=(ch.remote_pubkey.to_string()) {}
917
918 div class="form-check mb-3" {
919 input class="form-check-input"
920 type="checkbox"
921 name="force"
922 value="true"
923 id=(format!("force-{}", ch.remote_pubkey))
924 onchange=(format!(
925 "const input = document.getElementById('sats-vb-{}'); \
926 input.disabled = this.checked;",
927 ch.remote_pubkey
928 )) {}
929 label class="form-check-label"
930 for=(format!("force-{}", ch.remote_pubkey)) {
931 "Force Close"
932 }
933 }
934
935 @if is_lnd {
939 div class="mb-3" id=(format!("sats-vb-div-{}", ch.remote_pubkey)) {
940 label class="form-label" for=(format!("sats-vb-{}", ch.remote_pubkey)) {
941 "Sats per vbyte"
942 }
943 input
944 type="number"
945 min="1"
946 step="1"
947 class="form-control"
948 id=(format!("sats-vb-{}", ch.remote_pubkey))
949 name="sats_per_vbyte"
950 required
951 placeholder="Enter fee rate" {}
952
953 small class="text-muted" {
954 "Required for LND fee estimation"
955 }
956 }
957 } @else {
958 input type="hidden"
960 name="sats_per_vbyte"
961 value="1" {}
962 }
963
964 div class="htmx-indicator mt-2"
966 id=(format!("close-spinner-{}", ch.remote_pubkey)) {
967 div class="spinner-border spinner-border-sm text-danger" role="status" {}
968 span { " Closing..." }
969 }
970
971 button type="submit"
972 class="btn btn-danger btn-sm" {
973 "Confirm Close"
974 }
975 }
976 }
977 }
978 }
979 }
980 }
981 }
982 }
983
984 div class="mt-3" {
985 button id="open-channel-btn" class="btn btn-sm btn-primary"
987 type="button"
988 data-bs-toggle="collapse"
989 data-bs-target="#open-channel-form"
990 aria-expanded="false"
991 aria-controls="open-channel-form"
992 { "Open Channel" }
993
994 div id="open-channel-form" class="collapse mt-3" {
996 form hx-post=(OPEN_CHANNEL_ROUTE)
997 hx-target="#channels-container"
998 hx-swap="outerHTML"
999 class="card card-body" {
1000
1001 h5 class="card-title" { "Open New Channel" }
1002
1003 div class="mb-2" {
1004 label class="form-label" { "Remote Node Public Key" }
1005 input type="text" name="pubkey" class="form-control" placeholder="03abcd..." required {}
1006 }
1007
1008 div class="mb-2" {
1009 label class="form-label" { "Host" }
1010 input type="text" name="host" class="form-control" placeholder="1.2.3.4:9735" required {}
1011 }
1012
1013 div class="mb-2" {
1014 label class="form-label" { "Channel Size (sats)" }
1015 input type="number" name="channel_size_sats" class="form-control" placeholder="1000000" required {}
1016 }
1017
1018 input type="hidden" name="push_amount_sats" value="0" {}
1019
1020 button type="submit" class="btn btn-success" { "Confirm Open" }
1021 }
1022 }
1023 }
1024 }
1025 }
1026 }
1027 }
1028}
1029
1030pub async fn channels_fragment_handler<E>(
1031 State(state): State<UiState<DynGatewayApi<E>>>,
1032 _auth: UserAuth,
1033) -> Html<String>
1034where
1035 E: std::fmt::Display,
1036{
1037 let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1038 let channels_result: Result<_, E> = state.api.handle_list_channels_msg().await;
1039
1040 let markup = channels_fragment_markup(channels_result, None, None, is_lnd);
1041 Html(markup.into_string())
1042}
1043
1044pub async fn open_channel_handler<E: Display + Send + Sync>(
1045 State(state): State<UiState<DynGatewayApi<E>>>,
1046 _auth: UserAuth,
1047 Form(payload): Form<OpenChannelRequest>,
1048) -> Html<String> {
1049 let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1050 match state.api.handle_open_channel_msg(payload).await {
1051 Ok(txid) => {
1052 let channels_result = state.api.handle_list_channels_msg().await;
1053 let markup = channels_fragment_markup(
1054 channels_result,
1055 Some(format!("Successfully initiated channel open. TxId: {txid}")),
1056 None,
1057 is_lnd,
1058 );
1059 Html(markup.into_string())
1060 }
1061 Err(err) => {
1062 let channels_result = state.api.handle_list_channels_msg().await;
1063 let markup =
1064 channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1065 Html(markup.into_string())
1066 }
1067 }
1068}
1069
1070pub async fn close_channel_handler<E: Display + Send + Sync>(
1071 State(state): State<UiState<DynGatewayApi<E>>>,
1072 _auth: UserAuth,
1073 Form(payload): Form<CloseChannelsWithPeerRequest>,
1074) -> Html<String> {
1075 let is_lnd = matches!(state.api.lightning_mode(), LightningMode::Lnd { .. });
1076 match state.api.handle_close_channels_with_peer_msg(payload).await {
1077 Ok(_) => {
1078 let channels_result = state.api.handle_list_channels_msg().await;
1079 let markup = channels_fragment_markup(
1080 channels_result,
1081 Some("Successfully initiated channel close".to_string()),
1082 None,
1083 is_lnd,
1084 );
1085 Html(markup.into_string())
1086 }
1087 Err(err) => {
1088 let channels_result = state.api.handle_list_channels_msg().await;
1089 let markup =
1090 channels_fragment_markup(channels_result, None, Some(err.to_string()), is_lnd);
1091 Html(markup.into_string())
1092 }
1093 }
1094}
1095
1096pub async fn send_onchain_handler<E: Display + Send + Sync>(
1097 State(state): State<UiState<DynGatewayApi<E>>>,
1098 _auth: UserAuth,
1099 Form(payload): Form<SendOnchainRequest>,
1100) -> Html<String> {
1101 let result = state.api.handle_send_onchain_msg(payload).await;
1102
1103 let balances = state.api.handle_get_balances_msg().await;
1104
1105 let markup = match result {
1106 Ok(txid) => wallet_fragment_markup(
1107 &balances,
1108 Some(format!("Send transaction. TxId: {txid}")),
1109 None,
1110 ),
1111 Err(err) => wallet_fragment_markup(&balances, None, Some(err.to_string())),
1112 };
1113
1114 Html(markup.into_string())
1115}
1116
1117pub async fn wallet_fragment_handler<E>(
1118 State(state): State<UiState<DynGatewayApi<E>>>,
1119 _auth: UserAuth,
1120) -> Html<String>
1121where
1122 E: std::fmt::Display,
1123{
1124 let balances_result = state.api.handle_get_balances_msg().await;
1125 let markup = wallet_fragment_markup(&balances_result, None, None);
1126 Html(markup.into_string())
1127}
1128
1129pub async fn generate_receive_address_handler<E>(
1130 State(state): State<UiState<DynGatewayApi<E>>>,
1131 _auth: UserAuth,
1132) -> Html<String>
1133where
1134 E: std::fmt::Display,
1135{
1136 let address_result = state.api.handle_get_ln_onchain_address_msg().await;
1137
1138 let markup = match address_result {
1139 Ok(address) => {
1140 let code =
1142 QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
1143 let qr_svg = code.render::<svg::Color>().build();
1144
1145 html! {
1146 div class="card card-body bg-light d-flex flex-column align-items-center" {
1147 span class="fw-bold mb-3" { "Deposit Address:" }
1148
1149 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
1151
1152 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
1154 input type="text"
1155 readonly
1156 class="form-control mb-2"
1157 style="text-align:left; font-family: monospace; font-size:1rem;"
1158 value=(address)
1159 onclick="copyToClipboard(this)"
1160 {}
1161 small class="text-muted" { "Click to copy" }
1162 }
1163
1164 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
1166 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
1167 {
1168 (PreEscaped(format!(
1169 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
1170 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
1171 )))
1172 }
1173 }
1174 }
1175 }
1176 }
1177 Err(err) => {
1178 html! {
1179 div class="alert alert-danger" { "Failed to generate address: " (err) }
1180 }
1181 }
1182 };
1183
1184 Html(markup.into_string())
1185}
1186
1187pub async fn payments_fragment_handler<E>(
1188 State(state): State<UiState<DynGatewayApi<E>>>,
1189 _auth: UserAuth,
1190) -> Html<String>
1191where
1192 E: std::fmt::Display,
1193{
1194 let balances_result = state.api.handle_get_balances_msg().await;
1195 let markup = payments_fragment_markup(&balances_result, None, None, None);
1196 Html(markup.into_string())
1197}
1198
1199pub async fn create_bolt11_invoice_handler<E>(
1200 State(state): State<UiState<DynGatewayApi<E>>>,
1201 _auth: UserAuth,
1202 Form(payload): Form<CreateInvoiceForOperatorPayload>,
1203) -> Html<String>
1204where
1205 E: std::fmt::Display,
1206{
1207 let invoice_result = state
1208 .api
1209 .handle_create_invoice_for_operator_msg(payload)
1210 .await;
1211 let balances_result = state.api.handle_get_balances_msg().await;
1212
1213 match invoice_result {
1214 Ok(invoice) => {
1215 let markup =
1216 payments_fragment_markup(&balances_result, Some(invoice.to_string()), None, None);
1217 Html(markup.into_string())
1218 }
1219 Err(e) => {
1220 let markup = payments_fragment_markup(
1221 &balances_result,
1222 None,
1223 None,
1224 Some(format!("Failed to create invoice: {e}")),
1225 );
1226 Html(markup.into_string())
1227 }
1228 }
1229}
1230
1231pub async fn pay_bolt11_invoice_handler<E>(
1232 State(state): State<UiState<DynGatewayApi<E>>>,
1233 _auth: UserAuth,
1234 Form(payload): Form<PayInvoiceForOperatorPayload>,
1235) -> Html<String>
1236where
1237 E: std::fmt::Display,
1238{
1239 let send_result = state.api.handle_pay_invoice_for_operator_msg(payload).await;
1240 let balances_result = state.api.handle_get_balances_msg().await;
1241
1242 match send_result {
1243 Ok(preimage) => {
1244 let markup = payments_fragment_markup(
1245 &balances_result,
1246 None,
1247 Some(format!("Successfully paid invoice. Preimage: {preimage}")),
1248 None,
1249 );
1250 Html(markup.into_string())
1251 }
1252 Err(e) => {
1253 let markup = payments_fragment_markup(
1254 &balances_result,
1255 None,
1256 None,
1257 Some(format!("Failed to pay invoice: {e}")),
1258 );
1259 Html(markup.into_string())
1260 }
1261 }
1262}
1263
1264pub async fn transactions_fragment_handler<E>(
1265 State(state): State<UiState<DynGatewayApi<E>>>,
1266 _auth: UserAuth,
1267 Query(params): Query<HashMap<String, String>>,
1268) -> Html<String>
1269where
1270 E: std::fmt::Display + std::fmt::Debug,
1271{
1272 let now = fedimint_core::time::now();
1273 let end_secs = now
1274 .duration_since(std::time::UNIX_EPOCH)
1275 .expect("Time went backwards")
1276 .as_secs();
1277
1278 let start_secs = now
1279 .checked_sub(std::time::Duration::from_secs(60 * 60 * 24))
1280 .unwrap_or(now)
1281 .duration_since(std::time::UNIX_EPOCH)
1282 .expect("Time went backwards")
1283 .as_secs();
1284
1285 let parse = |key: &str| -> Option<u64> {
1286 params.get(key).and_then(|s| {
1287 chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")
1288 .ok()
1289 .map(|dt| {
1290 let dt_utc: chrono::DateTime<Utc> = Utc.from_utc_datetime(&dt);
1291 dt_utc.timestamp() as u64
1292 })
1293 })
1294 };
1295
1296 let start_secs = parse("start_secs").unwrap_or(start_secs);
1297 let end_secs = parse("end_secs").unwrap_or(end_secs);
1298
1299 let transactions_result = state
1300 .api
1301 .handle_list_transactions_msg(ListTransactionsPayload {
1302 start_secs,
1303 end_secs,
1304 })
1305 .await;
1306
1307 Html(transactions_fragment_markup(&transactions_result, start_secs, end_secs).into_string())
1308}