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