1use std::collections::BTreeMap;
2use std::fmt::Display;
3use std::str::FromStr;
4use std::time::{Duration, SystemTime};
5
6use axum::Form;
7use axum::extract::{Path, State};
8use axum::response::{Html, IntoResponse};
9use bitcoin::Address;
10use bitcoin::address::NetworkUnchecked;
11use fedimint_core::base32::{self, FEDIMINT_PREFIX};
12use fedimint_core::config::FederationId;
13use fedimint_core::invite_code::InviteCode;
14use fedimint_core::{Amount, BitcoinAmountOrAll, PeerId, TieredCounts};
15use fedimint_gateway_common::{
16 DepositAddressPayload, FederationInfo, LeaveFedPayload, ReceiveEcashPayload, SetFeesPayload,
17 SetPaymentPolicyPayload, SpendEcashPayload, WithdrawPayload, WithdrawPreviewPayload,
18};
19use fedimint_mint_client::OOBNotes;
20use fedimint_ui_common::UiState;
21use fedimint_ui_common::auth::UserAuth;
22use fedimint_wallet_client::PegOutFees;
23use maud::{Markup, PreEscaped, html};
24use qrcode::QrCode;
25use qrcode::render::svg;
26use serde::Deserialize;
27
28use crate::{
29 DEPOSIT_ADDRESS_ROUTE, DynGatewayApi, RECEIVE_ECASH_ROUTE, SET_FEES_ROUTE,
30 SET_PAYMENT_POLICY_ROUTE, SPEND_ECASH_ROUTE, WITHDRAW_CONFIRM_ROUTE, WITHDRAW_PREVIEW_ROUTE,
31 redirect_error, redirect_success, redirect_success_with_export_reminder,
32};
33
34#[derive(Deserialize)]
35pub struct ReceiveEcashForm {
36 pub notes: String,
37}
38
39pub fn scripts() -> Markup {
40 html!(
41 script {
42 (PreEscaped(r#"
43 function toggleFeesEdit(id) {
44 const viewDiv = document.getElementById('fees-view-' + id);
45 const editDiv = document.getElementById('fees-edit-' + id);
46 if (viewDiv.style.display === 'none') {
47 viewDiv.style.display = '';
48 editDiv.style.display = 'none';
49 } else {
50 viewDiv.style.display = 'none';
51 editDiv.style.display = '';
52 }
53 }
54
55 function copyToClipboard(input) {
56 input.select();
57 document.execCommand('copy');
58 const hint = input.nextElementSibling;
59 hint.textContent = 'Copied!';
60 setTimeout(() => hint.textContent = 'Click to copy', 2000);
61 }
62
63 function copyText(input) {
64 input.select();
65 document.execCommand('copy');
66 input.style.outline = '2px solid #28a745';
67 setTimeout(() => input.style.outline = '', 1500);
68 }
69
70 // Initialize Bootstrap tooltips
71 document.addEventListener('DOMContentLoaded', function() {
72 var tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
73 tooltipTriggerList.forEach(function(el) {
74 new bootstrap.Tooltip(el);
75 });
76 });
77
78 // Format amount based on selected unit
79 function formatAmount(msats, unit) {
80 if (unit === 'btc') {
81 const btc = msats / 100000000000;
82 return btc.toFixed(8) + ' BTC';
83 } else if (unit === 'sats') {
84 const sats = msats / 1000;
85 if (Number.isInteger(sats)) {
86 return sats.toLocaleString() + ' sats';
87 }
88 return sats.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 3}) + ' sats';
89 } else {
90 return msats.toLocaleString() + ' msats';
91 }
92 }
93
94 // Update all amount displays within a federation card
95 function updateAmounts(fedId, unit) {
96 const card = document.getElementById('fed-card-' + fedId);
97 if (!card) return;
98
99 card.querySelectorAll('[data-msats]').forEach(function(el) {
100 const msats = parseInt(el.getAttribute('data-msats'), 10);
101 const display = el.querySelector('.amount-display');
102 if (display) {
103 display.textContent = formatAmount(msats, unit);
104 }
105 });
106 }
107
108 // Get currently selected unit for a federation
109 function getSelectedUnit(fedId) {
110 const checked = document.querySelector('input[name="unit-' + fedId + '"]:checked');
111 if (checked) {
112 return checked.value;
113 }
114 return 'btc';
115 }
116
117 // Initialize unit toggle listeners
118 document.addEventListener('DOMContentLoaded', function() {
119 document.querySelectorAll('.unit-toggle').forEach(function(toggle) {
120 const fedId = toggle.getAttribute('data-fed-id');
121 toggle.querySelectorAll('input[type="radio"]').forEach(function(radio) {
122 radio.addEventListener('change', function(e) {
123 updateAmounts(fedId, e.target.value);
124 });
125 });
126 });
127 });
128
129 // Re-apply unit formatting after HTMX swaps
130 document.addEventListener('htmx:afterSwap', function(evt) {
131 // Find the federation card this swap belongs to
132 const card = evt.target.closest('[id^="fed-card-"]');
133 if (card) {
134 const fedId = card.id.replace('fed-card-', '');
135 const unit = getSelectedUnit(fedId);
136 updateAmounts(fedId, unit);
137 }
138 });
139 "#))
140 }
141 )
142}
143
144pub fn render<E: Display>(
145 fed: &FederationInfo,
146 invite_codes: &BTreeMap<PeerId, (String, InviteCode)>,
147 note_summary: &Result<TieredCounts, E>,
148) -> Markup {
149 html!(
150 @let bal = fed.balance_msat;
151 @let balance_class = if bal == Amount::ZERO {
152 "alert alert-danger"
153 } else {
154 "alert alert-success"
155 };
156 @let last_backup_str = fed.last_backup_time
157 .map(time_ago)
158 .unwrap_or("Never".to_string());
159
160
161 @let fed_id_str = fed.federation_id.to_string();
162 @let btc_value = fed.balance_msat.msats as f64 / 100_000_000_000.0;
163
164 div class="row gy-4 mt-2" {
165 div class="col-12" {
166 div class="card h-100" id=(format!("fed-card-{}", fed_id_str)) {
167 div class="card-header dashboard-header d-flex justify-content-between align-items-center" {
168 div {
169 (fed.federation_name.clone().unwrap_or("Unnamed Federation".to_string()))
170 @if !fed.config.receive_enabled() {
171 span class="badge bg-danger ms-2" title="Incoming Lightning payments for this federation are rejected" { "Receives disabled" }
172 }
173 }
174
175 div class="d-flex align-items-center gap-2" {
176 div class="btn-group btn-group-sm unit-toggle" role="group" data-fed-id=(fed_id_str) {
178 input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-btc-{}", fed_id_str)) value="btc" checked;
179 label class="btn btn-outline-primary" for=(format!("unit-btc-{}", fed_id_str)) { "BTC" }
180
181 input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-sats-{}", fed_id_str)) value="sats";
182 label class="btn btn-outline-primary" for=(format!("unit-sats-{}", fed_id_str)) { "sats" }
183
184 input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-msats-{}", fed_id_str)) value="msats";
185 label class="btn btn-outline-primary" for=(format!("unit-msats-{}", fed_id_str)) { "msats" }
186 }
187
188 form method="post" action={(format!("/ui/federations/{}/leave", fed.federation_id))} {
189 button type="submit"
190 class="btn btn-outline-danger btn-sm"
191 title="Leave Federation"
192 onclick=("return confirm('Are you sure you want to leave this federation? You will need to re-connect the federation to access any remaining balance.');")
193 { "📤" }
194 }
195 }
196 }
197 div class="card-body" {
198 div id=(format!("balance-{}", fed.federation_id)) class=(balance_class) data-msats=(fed.balance_msat.msats) {
199 "Balance: " strong class="amount-display" { (format!("{:.8} BTC", btc_value)) }
200 }
201 div class="alert alert-secondary py-1 px-2 small" {
202 "Last Backup: " strong { (last_backup_str) }
203 }
204
205 ul class="nav nav-tabs" role="tablist" {
207 li class="nav-item" role="presentation" {
208 button class="nav-link active"
209 id={(format!("fees-tab-{}", fed.federation_id))}
210 data-bs-toggle="tab"
211 data-bs-target={(format!("#fees-tab-pane-{}", fed.federation_id))}
212 type="button"
213 role="tab"
214 { "Fees" }
215 }
216 li class="nav-item" role="presentation" {
217 button class="nav-link"
218 id={(format!("deposit-tab-{}", fed.federation_id))}
219 data-bs-toggle="tab"
220 data-bs-target={(format!("#deposit-tab-pane-{}", fed.federation_id))}
221 type="button"
222 role="tab"
223 { "Deposit" }
224 }
225 li class="nav-item" role="presentation" {
226 button class="nav-link"
227 id={(format!("withdraw-tab-{}", fed.federation_id))}
228 data-bs-toggle="tab"
229 data-bs-target={(format!("#withdraw-tab-pane-{}", fed.federation_id))}
230 type="button"
231 role="tab"
232 { "Withdraw" }
233 }
234 li class="nav-item" role="presentation" {
235 button class="nav-link"
236 id={(format!("spend-tab-{}", fed.federation_id))}
237 data-bs-toggle="tab"
238 data-bs-target={(format!("#spend-tab-pane-{}", fed.federation_id))}
239 type="button"
240 role="tab"
241 { "Spend" }
242 }
243 li class="nav-item" role="presentation" {
244 button class="nav-link"
245 id=(format!("receive-tab-{}", fed.federation_id))
246 data-bs-toggle="tab"
247 data-bs-target=(format!("#receive-tab-pane-{}", fed.federation_id))
248 type="button"
249 role="tab"
250 { "Receive" }
251 }
252 li class="nav-item" role="presentation" {
253 button class="nav-link"
254 id=(format!("peers-tab-{}", fed.federation_id))
255 data-bs-toggle="tab"
256 data-bs-target=(format!("#peers-tab-pane-{}", fed.federation_id))
257 type="button"
258 role="tab"
259 { "Peers" }
260 }
261 li class="nav-item" role="presentation" {
262 button class="nav-link"
263 id=(format!("notes-tab-{}", fed.federation_id))
264 data-bs-toggle="tab"
265 data-bs-target=(format!("#notes-tab-pane-{}", fed.federation_id))
266 type="button"
267 role="tab"
268 { "Notes" }
269 }
270 }
271
272 div class="tab-content mt-3" {
273
274 div class="tab-pane fade show active"
278 id={(format!("fees-tab-pane-{}", fed.federation_id))}
279 role="tabpanel"
280 aria-labelledby={(format!("fees-tab-{}", fed.federation_id))} {
281
282 div id={(format!("fees-view-{}", fed.federation_id))} {
284 table class="table table-sm mb-2" {
285 tbody {
286 tr {
287 th {
288 "Lightning Base Fee "
289 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
290 }
291 td { (fed.config.lightning_fee.base) }
292 }
293 tr {
294 th {
295 "Lightning PPM "
296 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
297 }
298 td { (fed.config.lightning_fee.parts_per_million) }
299 }
300 tr {
301 th {
302 "Transaction Base Fee "
303 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
304 }
305 td { (fed.config.transaction_fee.base) }
306 }
307 tr {
308 th {
309 "Transaction PPM "
310 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
311 }
312 td { (fed.config.transaction_fee.parts_per_million) }
313 }
314 }
315 }
316
317 button
318 class="btn btn-sm btn-outline-primary"
319 type="button"
320 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
321 {
322 "Edit Fees"
323 }
324 }
325
326 div id={(format!("fees-edit-{}", fed.federation_id))} style="display: none;" {
328 form
329 method="post"
330 action={(SET_FEES_ROUTE)}
331 {
332 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
333 table class="table table-sm mb-2" {
334 tbody {
335 tr {
336 th {
337 "Lightning Base Fee "
338 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
339 }
340 td {
341 input type="number"
342 class="form-control form-control-sm"
343 name="lightning_base"
344 value=(fed.config.lightning_fee.base.msats);
345 }
346 }
347 tr {
348 th {
349 "Lightning PPM "
350 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
351 }
352 td {
353 input type="number"
354 class="form-control form-control-sm"
355 name="lightning_parts_per_million"
356 value=(fed.config.lightning_fee.parts_per_million);
357 }
358 }
359 tr {
360 th {
361 "Transaction Base Fee "
362 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
363 }
364 td {
365 input type="number"
366 class="form-control form-control-sm"
367 name="transaction_base"
368 value=(fed.config.transaction_fee.base.msats);
369 }
370 }
371 tr {
372 th {
373 "Transaction PPM "
374 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
375 }
376 td {
377 input type="number"
378 class="form-control form-control-sm"
379 name="transaction_parts_per_million"
380 value=(fed.config.transaction_fee.parts_per_million);
381 }
382 }
383 }
384 }
385
386 button type="submit" class="btn btn-sm btn-primary me-2" { "Save Fees" }
387 button
388 type="button"
389 class="btn btn-sm btn-secondary"
390 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
391 {
392 "Cancel"
393 }
394 }
395 }
396
397 div class="mt-3" {
399 table class="table table-sm mb-2" {
400 tbody {
401 tr {
402 th {
403 "Receives "
404 span class="text-muted" data-bs-toggle="tooltip" title="Whether the gateway accepts incoming Lightning payments on behalf of this federation's clients" { "ⓘ" }
405 }
406 td {
407 @if fed.config.receive_enabled() {
408 span class="badge bg-success" { "Enabled" }
409 } @else {
410 span class="badge bg-danger" { "Disabled" }
411 }
412 }
413 }
414 }
415 }
416
417 form method="post" action={(SET_PAYMENT_POLICY_ROUTE)} {
418 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
419 @if fed.config.receive_enabled() {
420 input type="hidden" name="receive_enabled" value="false";
421 button type="submit"
422 class="btn btn-sm btn-outline-danger"
423 onclick=("return confirm('Turn off receives for this federation? Incoming Lightning payments will be rejected, including payments of invoices that were already issued.');")
424 { "Disable Receives" }
425 } @else {
426 input type="hidden" name="receive_enabled" value="true";
427 button type="submit" class="btn btn-sm btn-outline-success" { "Enable Receives" }
428 }
429 }
430 }
431 }
432
433 div class="tab-pane fade"
437 id={(format!("deposit-tab-pane-{}", fed.federation_id))}
438 role="tabpanel"
439 aria-labelledby={(format!("deposit-tab-{}", fed.federation_id))} {
440
441 form hx-post=(DEPOSIT_ADDRESS_ROUTE)
442 hx-target={(format!("#deposit-result-{}", fed.federation_id))}
443 hx-swap="innerHTML"
444 {
445 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
446 button type="submit"
447 class="btn btn-outline-primary btn-sm"
448 {
449 "New Deposit Address"
450 }
451 }
452
453 div id=(format!("deposit-result-{}", fed.federation_id)) {}
454 }
455
456 div class="tab-pane fade"
460 id={(format!("withdraw-tab-pane-{}", fed.federation_id))}
461 role="tabpanel"
462 aria-labelledby={(format!("withdraw-tab-{}", fed.federation_id))} {
463
464 form hx-post=(WITHDRAW_PREVIEW_ROUTE)
465 hx-target={(format!("#withdraw-result-{}", fed.federation_id))}
466 hx-swap="innerHTML"
467 class="mt-3"
468 id=(format!("withdraw-form-{}", fed.federation_id))
469 {
470 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
471
472 div class="mb-3" {
473 label class="form-label" for=(format!("withdraw-amount-{}", fed.federation_id)) { "Amount (sats or 'all')" }
474 input type="text"
475 class="form-control"
476 id=(format!("withdraw-amount-{}", fed.federation_id))
477 name="amount"
478 placeholder="e.g. 100000 or all"
479 required;
480 }
481
482 div class="mb-3" {
483 label class="form-label" for=(format!("withdraw-address-{}", fed.federation_id)) { "Bitcoin Address" }
484 input type="text"
485 class="form-control"
486 id=(format!("withdraw-address-{}", fed.federation_id))
487 name="address"
488 placeholder="bc1q..."
489 required;
490 }
491
492 button type="submit" class="btn btn-primary" { "Preview" }
493 }
494
495 div id=(format!("withdraw-result-{}", fed.federation_id)) class="mt-3" {}
496 }
497
498 div class="tab-pane fade"
502 id={(format!("spend-tab-pane-{}", fed.federation_id))}
503 role="tabpanel"
504 aria-labelledby={(format!("spend-tab-{}", fed.federation_id))} {
505
506 form hx-post=(SPEND_ECASH_ROUTE)
507 hx-target={(format!("#spend-result-{}", fed.federation_id))}
508 hx-swap="innerHTML"
509 {
510 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
511
512 div class="mb-3" {
514 label class="form-label" for={(format!("spend-amount-{}", fed.federation_id))} {
515 "Amount (msats)"
516 }
517 input type="number"
518 class="form-control"
519 id={(format!("spend-amount-{}", fed.federation_id))}
520 name="amount"
521 placeholder="1000"
522 min="1"
523 required;
524 }
525
526 button type="submit" class="btn btn-primary" { "Generate Ecash" }
527 }
528
529 div id=(format!("spend-result-{}", fed.federation_id)) class="mt-3" {}
530 }
531
532 div class="tab-pane fade"
536 id=(format!("receive-tab-pane-{}", fed.federation_id))
537 role="tabpanel"
538 aria-labelledby=(format!("receive-tab-{}", fed.federation_id)) {
539
540 form hx-post=(RECEIVE_ECASH_ROUTE)
541 hx-target=(format!("#receive-result-{}", fed.federation_id))
542 hx-swap="innerHTML"
543 {
544 div class="mb-3" {
545 label class="form-label" for=(format!("receive-notes-{}", fed.federation_id)) {
546 "Ecash Notes"
547 }
548 textarea
549 class="form-control font-monospace"
550 id=(format!("receive-notes-{}", fed.federation_id))
551 name="notes"
552 rows="4"
553 placeholder="Paste ecash string here..."
554 required {}
555 }
556
557 button type="submit" class="btn btn-primary" { "Receive Ecash" }
558 }
559
560 div id=(format!("receive-result-{}", fed.federation_id)) class="mt-3" {}
561 }
562
563 div class="tab-pane fade"
567 id=(format!("peers-tab-pane-{}", fed.federation_id))
568 role="tabpanel"
569 aria-labelledby=(format!("peers-tab-{}", fed.federation_id))
570 {
571 @if invite_codes.is_empty() {
572 div class="alert alert-secondary" {
573 "No invite codes found for this federation."
574 }
575 } @else {
576 table class="table table-sm" {
577 thead {
578 tr {
579 th { "Peer ID" }
580 th { "Name" }
581 th { "Invite Code" }
582 }
583 }
584 tbody {
585 @for (peer_id, (name, code)) in invite_codes {
586 @let code_str = code.to_string();
587 @let modal_id = format!("qr-modal-{}-{}", fed.federation_id, peer_id);
588 @let qr = QrCode::new(code_str.as_bytes()).expect("Failed to generate QR code");
589 @let qr_svg = qr.render::<svg::Color>().build();
590 tr {
591 td { (peer_id) }
592 td { (name) }
593 td {
594 div class="d-flex align-items-center gap-1" {
595 input type="text"
596 class="form-control form-control-sm"
597 value=(code_str)
598 readonly
599 onclick="copyText(this)"
600 style="cursor: pointer; font-size: 0.75rem;";
601 button type="button"
602 class="btn btn-sm btn-outline-secondary"
603 data-bs-toggle="modal"
604 data-bs-target=(format!("#{}", modal_id))
605 title="Show QR Code"
606 { "QR" }
607 }
608
609 div class="modal fade"
611 id=(modal_id)
612 tabindex="-1"
613 aria-hidden="true"
614 {
615 div class="modal-dialog modal-dialog-centered" {
616 div class="modal-content" {
617 div class="modal-header" {
618 h5 class="modal-title" {
619 "Invite Code — Peer " (peer_id) " (" (name) ")"
620 }
621 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
622 }
623 div class="modal-body d-flex justify-content-center" {
624 div class="border rounded p-2 bg-white"
625 style="width: 300px; height: 300px;"
626 {
627 (PreEscaped(format!(
628 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
629 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
630 )))
631 }
632 }
633 }
634 }
635 }
636 }
637 }
638 }
639 }
640 }
641 }
642 }
643
644 div class="tab-pane fade"
648 id=(format!("notes-tab-pane-{}", fed.federation_id))
649 role="tabpanel"
650 aria-labelledby=(format!("notes-tab-{}", fed.federation_id))
651 {
652 @match ¬e_summary {
653 Ok(notes) => {
654 @if notes.is_empty() {
655 div class="alert alert-secondary" {
656 "No notes in wallet."
657 }
658 } @else {
659 table class="table table-sm table-bordered mb-0" {
660 thead {
661 tr class="table-light" {
662 th { "Denomination" }
663 th { "Count" }
664 th { "Subtotal" }
665 }
666 }
667 tbody {
668 @for (denomination, count) in notes.iter() {
669 tr {
670 td { (denomination) }
671 td { (count) }
672 td { (denomination * (count as u64)) }
673 }
674 }
675 }
676 tfoot {
677 tr class="table-light fw-bold" {
678 td { "Total" }
679 td { (notes.count_items()) }
680 td { (notes.total_amount()) }
681 }
682 }
683 }
684 }
685 }
686 Err(err) => {
687 div class="alert alert-warning" {
688 "Could not load note summary: " (err)
689 }
690 }
691 }
692 }
693 }
694 }
695 }
696 }
697 }
698 )
699}
700
701fn time_ago(t: SystemTime) -> String {
702 let now = fedimint_core::time::now();
703 let diff = match now.duration_since(t) {
704 Ok(d) => d,
705 Err(_) => Duration::from_secs(0),
706 };
707
708 let secs = diff.as_secs();
709
710 match secs {
711 0..=59 => format!("{} seconds ago", secs),
712 60..=3599 => format!("{} minutes ago", secs / 60),
713 _ => format!("{} hours ago", secs / 3600),
714 }
715}
716
717pub async fn leave_federation_handler<E: Display>(
718 State(state): State<UiState<DynGatewayApi<E>>>,
719 Path(id): Path<String>,
720 _auth: UserAuth,
721) -> impl IntoResponse {
722 let federation_id = FederationId::from_str(&id);
723 if let Ok(federation_id) = federation_id {
724 match state
725 .api
726 .handle_leave_federation(LeaveFedPayload { federation_id })
727 .await
728 {
729 Ok(info) => {
730 redirect_success_with_export_reminder(format!(
732 "Successfully left {}.",
733 info.federation_name
734 .unwrap_or("Unnamed Federation".to_string())
735 ))
736 .into_response()
737 }
738 Err(err) => {
739 redirect_error(format!("Failed to leave federation: {err}")).into_response()
740 }
741 }
742 } else {
743 redirect_error("Failed to leave federation: Invalid federation id".to_string())
744 .into_response()
745 }
746}
747
748pub async fn set_fees_handler<E: Display>(
749 State(state): State<UiState<DynGatewayApi<E>>>,
750 _auth: UserAuth,
751 Form(payload): Form<SetFeesPayload>,
752) -> impl IntoResponse {
753 tracing::info!("Received fees payload: {:?}", payload);
754
755 match state.api.handle_set_fees_msg(payload).await {
756 Ok(_) => redirect_success("Successfully set fees".to_string()).into_response(),
757 Err(err) => redirect_error(format!("Failed to update fees: {err}")).into_response(),
758 }
759}
760
761pub async fn set_payment_policy_handler<E: Display>(
762 State(state): State<UiState<DynGatewayApi<E>>>,
763 _auth: UserAuth,
764 Form(payload): Form<SetPaymentPolicyPayload>,
765) -> impl IntoResponse {
766 tracing::info!(?payload, "Received payment policy payload");
767
768 match state.api.handle_set_payment_policy_msg(payload).await {
769 Ok(()) => {
770 redirect_success("Successfully updated payment policy".to_string()).into_response()
771 }
772 Err(err) => {
773 redirect_error(format!("Failed to update payment policy: {err}")).into_response()
774 }
775 }
776}
777
778pub async fn deposit_address_handler<E: Display>(
779 State(state): State<UiState<DynGatewayApi<E>>>,
780 _auth: UserAuth,
781 Form(payload): Form<DepositAddressPayload>,
782) -> impl IntoResponse {
783 let markup = match state.api.handle_deposit_address_msg(payload).await {
784 Ok(address) => {
785 let code =
786 QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
787 let qr_svg = code.render::<svg::Color>().build();
788
789 html! {
790 div class="card card-body bg-light d-flex flex-column align-items-center mt-2" {
791 span class="fw-bold mb-3" { "Deposit Address:" }
792
793 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
794
795 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
797 input type="text"
798 readonly
799 class="form-control mb-2"
800 style="text-align:left; font-family: monospace; font-size:1rem;"
801 value=(address)
802 onclick="copyToClipboard(this)"
803 {}
804 small class="text-muted" { "Click to copy" }
805 }
806
807 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
809 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
810 {
811 (PreEscaped(format!(
812 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
813 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
814 )))
815 }
816 }
817 }
818 }
819 }
820 Err(err) => {
821 html! {
822 div class="alert alert-danger mt-2" {
823 "Failed to generate deposit address: " (err)
824 }
825 }
826 }
827 };
828 Html(markup.into_string())
829}
830
831pub async fn withdraw_preview_handler<E: Display>(
834 State(state): State<UiState<DynGatewayApi<E>>>,
835 _auth: UserAuth,
836 Form(payload): Form<WithdrawPreviewPayload>,
837) -> impl IntoResponse {
838 let federation_id = payload.federation_id;
839 let is_max = matches!(payload.amount, BitcoinAmountOrAll::All);
840
841 let markup = match state.api.handle_withdraw_preview_msg(payload).await {
842 Ok(response) => {
843 let amount_label = if is_max {
844 format!("{} sats (max)", response.withdraw_amount.sats_round_down())
845 } else {
846 format!("{} sats", response.withdraw_amount.sats_round_down())
847 };
848
849 html! {
850 div class="card" {
851 div class="card-body" {
852 h6 class="card-title" { "Withdrawal Preview" }
853
854 table class="table table-sm" {
855 tbody {
856 tr {
857 td { "Amount" }
858 td { (amount_label) }
859 }
860 tr {
861 td { "Address" }
862 td class="text-break" style="font-family: monospace; font-size: 0.85em;" {
863 (response.address.clone())
864 }
865 }
866 tr {
867 td { "Fee Rate" }
868 td { (format!("{} sats/kvB", response.peg_out_fees.fee_rate.sats_per_kvb)) }
869 }
870 tr {
871 td { "Transaction Size" }
872 td { (format!("{} weight units", response.peg_out_fees.total_weight)) }
873 }
874 tr {
875 td { "Peg-out Fee" }
876 td { (format!("{} sats", response.peg_out_fees.amount().to_sat())) }
877 }
878 @if let Some(federation_fee) = response.mint_fees {
883 tr {
884 td { "Federation Fee (est.)" }
885 td { (format!("~{} sats", federation_fee.sats_round_down())) }
886 }
887 }
888 tr {
889 td { strong { "Total Deducted" } }
890 td { strong { (format!("{} sats", response.total_cost.sats_round_down())) } }
891 }
892 }
893 }
894
895 div class="d-flex gap-2 mt-3" {
896 form hx-post=(WITHDRAW_CONFIRM_ROUTE)
898 hx-target=(format!("#withdraw-result-{}", federation_id))
899 hx-swap="innerHTML"
900 {
901 input type="hidden" name="federation_id" value=(federation_id.to_string());
902 input type="hidden" name="amount" value=(response.withdraw_amount.sats_round_down().to_string());
903 input type="hidden" name="address" value=(response.address);
904 input type="hidden" name="fee_rate_sats_per_kvb" value=(response.peg_out_fees.fee_rate.sats_per_kvb.to_string());
905 input type="hidden" name="total_weight" value=(response.peg_out_fees.total_weight.to_string());
906
907 button type="submit" class="btn btn-success" { "Confirm Withdrawal" }
908 }
909
910 button type="button"
912 class="btn btn-outline-secondary"
913 onclick=(format!("document.getElementById('withdraw-result-{}').innerHTML = ''", federation_id))
914 { "Cancel" }
915 }
916 }
917 }
918 }
919 }
920 Err(err) => {
921 html! {
922 div class="alert alert-danger" {
923 "Error: " (err.to_string())
924 }
925 }
926 }
927 };
928 Html(markup.into_string())
929}
930
931#[derive(Debug, serde::Deserialize)]
933pub struct WithdrawConfirmPayload {
934 pub federation_id: FederationId,
935 pub amount: u64,
936 pub address: String,
937 pub fee_rate_sats_per_kvb: u64,
938 pub total_weight: u64,
939}
940
941pub async fn withdraw_confirm_handler<E: Display>(
944 State(state): State<UiState<DynGatewayApi<E>>>,
945 _auth: UserAuth,
946 Form(payload): Form<WithdrawConfirmPayload>,
947) -> impl IntoResponse {
948 let federation_id = payload.federation_id;
949
950 let address: Address<NetworkUnchecked> = match payload.address.parse() {
952 Ok(addr) => addr,
953 Err(err) => {
954 return Html(
955 html! {
956 div class="alert alert-danger" {
957 "Error parsing address: " (err.to_string())
958 }
959 }
960 .into_string(),
961 );
962 }
963 };
964
965 let withdraw_payload = WithdrawPayload {
967 federation_id,
968 amount: BitcoinAmountOrAll::Amount(bitcoin::Amount::from_sat(payload.amount)),
969 address,
970 quoted_fees: Some(PegOutFees::new(
971 payload.fee_rate_sats_per_kvb,
972 payload.total_weight,
973 )),
974 };
975
976 let markup = match state.api.handle_withdraw_msg(withdraw_payload).await {
977 Ok(response) => {
978 let updated_balance = state
980 .api
981 .handle_get_balances_msg()
982 .await
983 .ok()
984 .and_then(|balances| {
985 balances
986 .ecash_balances
987 .into_iter()
988 .find(|b| b.federation_id == federation_id)
989 .map(|b| b.ecash_balance_msats)
990 })
991 .unwrap_or(Amount::ZERO);
992
993 let balance_class = if updated_balance == Amount::ZERO {
994 "alert alert-danger"
995 } else {
996 "alert alert-success"
997 };
998
999 let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1000
1001 html! {
1002 div class="alert alert-success" {
1004 p { strong { "Withdrawal successful!" } }
1005 p { "Transaction ID: " code { (response.txid) } }
1006 p { "Peg-out Fee: " (format!("{} sats", response.fees.amount().to_sat())) }
1007 }
1008
1009 div id=(format!("balance-{}", federation_id))
1011 class=(balance_class)
1012 data-msats=(updated_balance.msats)
1013 hx-swap-oob="true"
1014 {
1015 "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1016 }
1017 }
1018 }
1019 Err(err) => {
1020 html! {
1021 div class="alert alert-danger" {
1022 "Error: " (err.to_string())
1023 }
1024 }
1025 }
1026 };
1027 Html(markup.into_string())
1028}
1029
1030pub async fn spend_ecash_handler<E: Display>(
1031 State(state): State<UiState<DynGatewayApi<E>>>,
1032 _auth: UserAuth,
1033 Form(payload): Form<SpendEcashPayload>,
1034) -> impl IntoResponse {
1035 let federation_id = payload.federation_id;
1036 let requested_amount = payload.amount;
1037
1038 let markup = match state.api.handle_spend_ecash_msg(payload).await {
1039 Ok(response) => {
1040 let notes_string = response.notes.clone();
1041
1042 let actual_amount = if let Ok(ecash) = base32::decode_prefixed::<
1044 fedimint_mintv2_client::ECash,
1045 >(FEDIMINT_PREFIX, ¬es_string)
1046 {
1047 ecash.amount()
1048 } else if let Ok(notes) = notes_string.parse::<OOBNotes>() {
1049 notes.total_amount()
1050 } else {
1051 return Html(
1052 html! {
1053 div class="alert alert-danger" {
1054 "Failed to parse returned ecash notes"
1055 }
1056 }
1057 .into_string(),
1058 );
1059 };
1060 let overspent = actual_amount > requested_amount;
1061
1062 let updated_balance = state
1064 .api
1065 .handle_get_balances_msg()
1066 .await
1067 .ok()
1068 .and_then(|balances| {
1069 balances
1070 .ecash_balances
1071 .into_iter()
1072 .find(|b| b.federation_id == federation_id)
1073 .map(|b| b.ecash_balance_msats)
1074 })
1075 .unwrap_or(Amount::ZERO);
1076
1077 let balance_class = if updated_balance == Amount::ZERO {
1078 "alert alert-danger"
1079 } else {
1080 "alert alert-success"
1081 };
1082
1083 let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1084
1085 html! {
1086 div class="card card-body bg-light" {
1087 div class="d-flex justify-content-between align-items-center mb-2" {
1088 span class="fw-bold" { "Ecash Generated" }
1089 span class="badge bg-success" { (actual_amount) }
1090 }
1091
1092 @if overspent {
1093 div class="alert alert-warning py-2 mb-2" {
1094 "Note: Spent " (actual_amount) " ("
1095 (actual_amount.saturating_sub(requested_amount))
1096 " more than requested due to note denominations)"
1097 }
1098 }
1099
1100 div class="mb-2" {
1101 label class="form-label small text-muted" { "Ecash Notes (click to copy):" }
1102 textarea
1103 class="form-control font-monospace"
1104 rows="4"
1105 readonly
1106 onclick="copyToClipboard(this)"
1107 style="font-size: 0.85rem;"
1108 { (notes_string) }
1109 small class="text-muted" { "Click to copy" }
1110 }
1111 }
1112
1113 div id=(format!("balance-{}", federation_id))
1115 class=(balance_class)
1116 data-msats=(updated_balance.msats)
1117 hx-swap-oob="true"
1118 {
1119 "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1120 }
1121 }
1122 }
1123 Err(err) => {
1124 html! {
1125 div class="alert alert-danger" {
1126 "Failed to generate ecash: " (err)
1127 }
1128 }
1129 }
1130 };
1131 Html(markup.into_string())
1132}
1133
1134pub async fn receive_ecash_handler<E: Display>(
1135 State(state): State<UiState<DynGatewayApi<E>>>,
1136 _auth: UserAuth,
1137 Form(form): Form<ReceiveEcashForm>,
1138) -> impl IntoResponse {
1139 let notes_str = form.notes.trim().to_string();
1140
1141 let federation_id_prefix = if let Ok(ecash) =
1143 base32::decode_prefixed::<fedimint_mintv2_client::ECash>(FEDIMINT_PREFIX, ¬es_str)
1144 {
1145 match ecash.mint() {
1146 Some(fed_id) => fed_id.to_prefix(),
1147 None => {
1148 return Html(
1149 html! {
1150 div class="alert alert-danger" {
1151 "Invalid ecash format: missing federation ID"
1152 }
1153 }
1154 .into_string(),
1155 );
1156 }
1157 }
1158 } else if let Ok(notes) = notes_str.parse::<OOBNotes>() {
1159 notes.federation_id_prefix()
1160 } else {
1161 return Html(
1162 html! {
1163 div class="alert alert-danger" {
1164 "Invalid ecash format: could not parse as ECash or OOBNotes"
1165 }
1166 }
1167 .into_string(),
1168 );
1169 };
1170
1171 let payload = ReceiveEcashPayload { notes: notes_str };
1173
1174 let markup = match state.api.handle_receive_ecash_msg(payload).await {
1175 Ok(response) => {
1176 let (federation_id, updated_balance) = state
1178 .api
1179 .handle_get_balances_msg()
1180 .await
1181 .ok()
1182 .and_then(|balances| {
1183 balances
1184 .ecash_balances
1185 .into_iter()
1186 .find(|b| b.federation_id.to_prefix() == federation_id_prefix)
1187 .map(|b| (b.federation_id, b.ecash_balance_msats))
1188 })
1189 .expect("Federation not found");
1190
1191 let balance_class = if updated_balance == Amount::ZERO {
1192 "alert alert-danger"
1193 } else {
1194 "alert alert-success"
1195 };
1196
1197 let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1198
1199 html! {
1200 div class="alert alert-success" {
1201 div class="d-flex justify-content-between align-items-center" {
1202 span { "Ecash received successfully!" }
1203 span class="badge bg-success" { (response.amount) }
1204 }
1205 }
1206
1207 div id=(format!("balance-{}", federation_id))
1209 class=(balance_class)
1210 data-msats=(updated_balance.msats)
1211 hx-swap-oob="true"
1212 {
1213 "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1214 }
1215 }
1216 }
1217 Err(err) => {
1218 html! {
1219 div class="alert alert-danger" {
1220 "Failed to receive ecash: " (err)
1221 }
1222 }
1223 }
1224 };
1225 Html(markup.into_string())
1226}