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