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