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}
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 }
171
172 div class="d-flex align-items-center gap-2" {
173 div class="btn-group btn-group-sm unit-toggle" role="group" data-fed-id=(fed_id_str) {
175 input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-btc-{}", fed_id_str)) value="btc" checked;
176 label class="btn btn-outline-primary" for=(format!("unit-btc-{}", fed_id_str)) { "BTC" }
177
178 input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-sats-{}", fed_id_str)) value="sats";
179 label class="btn btn-outline-primary" for=(format!("unit-sats-{}", fed_id_str)) { "sats" }
180
181 input type="radio" class="btn-check" name=(format!("unit-{}", fed_id_str)) id=(format!("unit-msats-{}", fed_id_str)) value="msats";
182 label class="btn btn-outline-primary" for=(format!("unit-msats-{}", fed_id_str)) { "msats" }
183 }
184
185 form method="post" action={(format!("/ui/federations/{}/leave", fed.federation_id))} {
186 button type="submit"
187 class="btn btn-outline-danger btn-sm"
188 title="Leave Federation"
189 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.');")
190 { "📤" }
191 }
192 }
193 }
194 div class="card-body" {
195 div id=(format!("balance-{}", fed.federation_id)) class=(balance_class) data-msats=(fed.balance_msat.msats) {
196 "Balance: " strong class="amount-display" { (format!("{:.8} BTC", btc_value)) }
197 }
198 div class="alert alert-secondary py-1 px-2 small" {
199 "Last Backup: " strong { (last_backup_str) }
200 }
201
202 ul class="nav nav-tabs" role="tablist" {
204 li class="nav-item" role="presentation" {
205 button class="nav-link active"
206 id={(format!("fees-tab-{}", fed.federation_id))}
207 data-bs-toggle="tab"
208 data-bs-target={(format!("#fees-tab-pane-{}", fed.federation_id))}
209 type="button"
210 role="tab"
211 { "Fees" }
212 }
213 li class="nav-item" role="presentation" {
214 button class="nav-link"
215 id={(format!("deposit-tab-{}", fed.federation_id))}
216 data-bs-toggle="tab"
217 data-bs-target={(format!("#deposit-tab-pane-{}", fed.federation_id))}
218 type="button"
219 role="tab"
220 { "Deposit" }
221 }
222 li class="nav-item" role="presentation" {
223 button class="nav-link"
224 id={(format!("withdraw-tab-{}", fed.federation_id))}
225 data-bs-toggle="tab"
226 data-bs-target={(format!("#withdraw-tab-pane-{}", fed.federation_id))}
227 type="button"
228 role="tab"
229 { "Withdraw" }
230 }
231 li class="nav-item" role="presentation" {
232 button class="nav-link"
233 id={(format!("spend-tab-{}", fed.federation_id))}
234 data-bs-toggle="tab"
235 data-bs-target={(format!("#spend-tab-pane-{}", fed.federation_id))}
236 type="button"
237 role="tab"
238 { "Spend" }
239 }
240 li class="nav-item" role="presentation" {
241 button class="nav-link"
242 id=(format!("receive-tab-{}", fed.federation_id))
243 data-bs-toggle="tab"
244 data-bs-target=(format!("#receive-tab-pane-{}", fed.federation_id))
245 type="button"
246 role="tab"
247 { "Receive" }
248 }
249 li class="nav-item" role="presentation" {
250 button class="nav-link"
251 id=(format!("peers-tab-{}", fed.federation_id))
252 data-bs-toggle="tab"
253 data-bs-target=(format!("#peers-tab-pane-{}", fed.federation_id))
254 type="button"
255 role="tab"
256 { "Peers" }
257 }
258 li class="nav-item" role="presentation" {
259 button class="nav-link"
260 id=(format!("notes-tab-{}", fed.federation_id))
261 data-bs-toggle="tab"
262 data-bs-target=(format!("#notes-tab-pane-{}", fed.federation_id))
263 type="button"
264 role="tab"
265 { "Notes" }
266 }
267 }
268
269 div class="tab-content mt-3" {
270
271 div class="tab-pane fade show active"
275 id={(format!("fees-tab-pane-{}", fed.federation_id))}
276 role="tabpanel"
277 aria-labelledby={(format!("fees-tab-{}", fed.federation_id))} {
278
279 div id={(format!("fees-view-{}", fed.federation_id))} {
281 table class="table table-sm mb-2" {
282 tbody {
283 tr {
284 th {
285 "Lightning Base Fee "
286 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
287 }
288 td { (fed.config.lightning_fee.base) }
289 }
290 tr {
291 th {
292 "Lightning PPM "
293 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
294 }
295 td { (fed.config.lightning_fee.parts_per_million) }
296 }
297 tr {
298 th {
299 "Transaction Base Fee "
300 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
301 }
302 td { (fed.config.transaction_fee.base) }
303 }
304 tr {
305 th {
306 "Transaction PPM "
307 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
308 }
309 td { (fed.config.transaction_fee.parts_per_million) }
310 }
311 }
312 }
313
314 button
315 class="btn btn-sm btn-outline-primary"
316 type="button"
317 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
318 {
319 "Edit Fees"
320 }
321 }
322
323 div id={(format!("fees-edit-{}", fed.federation_id))} style="display: none;" {
325 form
326 method="post"
327 action={(SET_FEES_ROUTE)}
328 {
329 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
330 table class="table table-sm mb-2" {
331 tbody {
332 tr {
333 th {
334 "Lightning Base Fee "
335 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
336 }
337 td {
338 input type="number"
339 class="form-control form-control-sm"
340 name="lightning_base"
341 value=(fed.config.lightning_fee.base.msats);
342 }
343 }
344 tr {
345 th {
346 "Lightning PPM "
347 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
348 }
349 td {
350 input type="number"
351 class="form-control form-control-sm"
352 name="lightning_parts_per_million"
353 value=(fed.config.lightning_fee.parts_per_million);
354 }
355 }
356 tr {
357 th {
358 "Transaction Base Fee "
359 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
360 }
361 td {
362 input type="number"
363 class="form-control form-control-sm"
364 name="transaction_base"
365 value=(fed.config.transaction_fee.base.msats);
366 }
367 }
368 tr {
369 th {
370 "Transaction PPM "
371 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
372 }
373 td {
374 input type="number"
375 class="form-control form-control-sm"
376 name="transaction_parts_per_million"
377 value=(fed.config.transaction_fee.parts_per_million);
378 }
379 }
380 }
381 }
382
383 button type="submit" class="btn btn-sm btn-primary me-2" { "Save Fees" }
384 button
385 type="button"
386 class="btn btn-sm btn-secondary"
387 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
388 {
389 "Cancel"
390 }
391 }
392 }
393 }
394
395 div class="tab-pane fade"
399 id={(format!("deposit-tab-pane-{}", fed.federation_id))}
400 role="tabpanel"
401 aria-labelledby={(format!("deposit-tab-{}", fed.federation_id))} {
402
403 form hx-post=(DEPOSIT_ADDRESS_ROUTE)
404 hx-target={(format!("#deposit-result-{}", fed.federation_id))}
405 hx-swap="innerHTML"
406 {
407 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
408 button type="submit"
409 class="btn btn-outline-primary btn-sm"
410 {
411 "New Deposit Address"
412 }
413 }
414
415 div id=(format!("deposit-result-{}", fed.federation_id)) {}
416 }
417
418 div class="tab-pane fade"
422 id={(format!("withdraw-tab-pane-{}", fed.federation_id))}
423 role="tabpanel"
424 aria-labelledby={(format!("withdraw-tab-{}", fed.federation_id))} {
425
426 form hx-post=(WITHDRAW_PREVIEW_ROUTE)
427 hx-target={(format!("#withdraw-result-{}", fed.federation_id))}
428 hx-swap="innerHTML"
429 class="mt-3"
430 id=(format!("withdraw-form-{}", fed.federation_id))
431 {
432 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
433
434 div class="mb-3" {
435 label class="form-label" for=(format!("withdraw-amount-{}", fed.federation_id)) { "Amount (sats or 'all')" }
436 input type="text"
437 class="form-control"
438 id=(format!("withdraw-amount-{}", fed.federation_id))
439 name="amount"
440 placeholder="e.g. 100000 or all"
441 required;
442 }
443
444 div class="mb-3" {
445 label class="form-label" for=(format!("withdraw-address-{}", fed.federation_id)) { "Bitcoin Address" }
446 input type="text"
447 class="form-control"
448 id=(format!("withdraw-address-{}", fed.federation_id))
449 name="address"
450 placeholder="bc1q..."
451 required;
452 }
453
454 button type="submit" class="btn btn-primary" { "Preview" }
455 }
456
457 div id=(format!("withdraw-result-{}", fed.federation_id)) class="mt-3" {}
458 }
459
460 div class="tab-pane fade"
464 id={(format!("spend-tab-pane-{}", fed.federation_id))}
465 role="tabpanel"
466 aria-labelledby={(format!("spend-tab-{}", fed.federation_id))} {
467
468 form hx-post=(SPEND_ECASH_ROUTE)
469 hx-target={(format!("#spend-result-{}", fed.federation_id))}
470 hx-swap="innerHTML"
471 {
472 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
473
474 div class="mb-3" {
476 label class="form-label" for={(format!("spend-amount-{}", fed.federation_id))} {
477 "Amount (msats)"
478 }
479 input type="number"
480 class="form-control"
481 id={(format!("spend-amount-{}", fed.federation_id))}
482 name="amount"
483 placeholder="1000"
484 min="1"
485 required;
486 }
487
488 button type="submit" class="btn btn-primary" { "Generate Ecash" }
489 }
490
491 div id=(format!("spend-result-{}", fed.federation_id)) class="mt-3" {}
492 }
493
494 div class="tab-pane fade"
498 id=(format!("receive-tab-pane-{}", fed.federation_id))
499 role="tabpanel"
500 aria-labelledby=(format!("receive-tab-{}", fed.federation_id)) {
501
502 form hx-post=(RECEIVE_ECASH_ROUTE)
503 hx-target=(format!("#receive-result-{}", fed.federation_id))
504 hx-swap="innerHTML"
505 {
506 div class="mb-3" {
507 label class="form-label" for=(format!("receive-notes-{}", fed.federation_id)) {
508 "Ecash Notes"
509 }
510 textarea
511 class="form-control font-monospace"
512 id=(format!("receive-notes-{}", fed.federation_id))
513 name="notes"
514 rows="4"
515 placeholder="Paste ecash string here..."
516 required {}
517 }
518
519 button type="submit" class="btn btn-primary" { "Receive Ecash" }
520 }
521
522 div id=(format!("receive-result-{}", fed.federation_id)) class="mt-3" {}
523 }
524
525 div class="tab-pane fade"
529 id=(format!("peers-tab-pane-{}", fed.federation_id))
530 role="tabpanel"
531 aria-labelledby=(format!("peers-tab-{}", fed.federation_id))
532 {
533 @if invite_codes.is_empty() {
534 div class="alert alert-secondary" {
535 "No invite codes found for this federation."
536 }
537 } @else {
538 table class="table table-sm" {
539 thead {
540 tr {
541 th { "Peer ID" }
542 th { "Name" }
543 th { "Invite Code" }
544 }
545 }
546 tbody {
547 @for (peer_id, (name, code)) in invite_codes {
548 @let code_str = code.to_string();
549 @let modal_id = format!("qr-modal-{}-{}", fed.federation_id, peer_id);
550 @let qr = QrCode::new(code_str.as_bytes()).expect("Failed to generate QR code");
551 @let qr_svg = qr.render::<svg::Color>().build();
552 tr {
553 td { (peer_id) }
554 td { (name) }
555 td {
556 div class="d-flex align-items-center gap-1" {
557 input type="text"
558 class="form-control form-control-sm"
559 value=(code_str)
560 readonly
561 onclick="copyText(this)"
562 style="cursor: pointer; font-size: 0.75rem;";
563 button type="button"
564 class="btn btn-sm btn-outline-secondary"
565 data-bs-toggle="modal"
566 data-bs-target=(format!("#{}", modal_id))
567 title="Show QR Code"
568 { "QR" }
569 }
570
571 div class="modal fade"
573 id=(modal_id)
574 tabindex="-1"
575 aria-hidden="true"
576 {
577 div class="modal-dialog modal-dialog-centered" {
578 div class="modal-content" {
579 div class="modal-header" {
580 h5 class="modal-title" {
581 "Invite Code — Peer " (peer_id) " (" (name) ")"
582 }
583 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
584 }
585 div class="modal-body d-flex justify-content-center" {
586 div class="border rounded p-2 bg-white"
587 style="width: 300px; height: 300px;"
588 {
589 (PreEscaped(format!(
590 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
591 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
592 )))
593 }
594 }
595 }
596 }
597 }
598 }
599 }
600 }
601 }
602 }
603 }
604 }
605
606 div class="tab-pane fade"
610 id=(format!("notes-tab-pane-{}", fed.federation_id))
611 role="tabpanel"
612 aria-labelledby=(format!("notes-tab-{}", fed.federation_id))
613 {
614 @match ¬e_summary {
615 Ok(notes) => {
616 @if notes.is_empty() {
617 div class="alert alert-secondary" {
618 "No notes in wallet."
619 }
620 } @else {
621 table class="table table-sm table-bordered mb-0" {
622 thead {
623 tr class="table-light" {
624 th { "Denomination" }
625 th { "Count" }
626 th { "Subtotal" }
627 }
628 }
629 tbody {
630 @for (denomination, count) in notes.iter() {
631 tr {
632 td { (denomination) }
633 td { (count) }
634 td { (denomination * (count as u64)) }
635 }
636 }
637 }
638 tfoot {
639 tr class="table-light fw-bold" {
640 td { "Total" }
641 td { (notes.count_items()) }
642 td { (notes.total_amount()) }
643 }
644 }
645 }
646 }
647 }
648 Err(err) => {
649 div class="alert alert-warning" {
650 "Could not load note summary: " (err)
651 }
652 }
653 }
654 }
655 }
656 }
657 }
658 }
659 }
660 )
661}
662
663fn time_ago(t: SystemTime) -> String {
664 let now = fedimint_core::time::now();
665 let diff = match now.duration_since(t) {
666 Ok(d) => d,
667 Err(_) => Duration::from_secs(0),
668 };
669
670 let secs = diff.as_secs();
671
672 match secs {
673 0..=59 => format!("{} seconds ago", secs),
674 60..=3599 => format!("{} minutes ago", secs / 60),
675 _ => format!("{} hours ago", secs / 3600),
676 }
677}
678
679pub async fn leave_federation_handler<E: Display>(
680 State(state): State<UiState<DynGatewayApi<E>>>,
681 Path(id): Path<String>,
682 _auth: UserAuth,
683) -> impl IntoResponse {
684 let federation_id = FederationId::from_str(&id);
685 if let Ok(federation_id) = federation_id {
686 match state
687 .api
688 .handle_leave_federation(LeaveFedPayload { federation_id })
689 .await
690 {
691 Ok(info) => {
692 redirect_success_with_export_reminder(format!(
694 "Successfully left {}.",
695 info.federation_name
696 .unwrap_or("Unnamed Federation".to_string())
697 ))
698 .into_response()
699 }
700 Err(err) => {
701 redirect_error(format!("Failed to leave federation: {err}")).into_response()
702 }
703 }
704 } else {
705 redirect_error("Failed to leave federation: Invalid federation id".to_string())
706 .into_response()
707 }
708}
709
710pub async fn set_fees_handler<E: Display>(
711 State(state): State<UiState<DynGatewayApi<E>>>,
712 _auth: UserAuth,
713 Form(payload): Form<SetFeesPayload>,
714) -> impl IntoResponse {
715 tracing::info!("Received fees payload: {:?}", payload);
716
717 match state.api.handle_set_fees_msg(payload).await {
718 Ok(_) => redirect_success("Successfully set fees".to_string()).into_response(),
719 Err(err) => redirect_error(format!("Failed to update fees: {err}")).into_response(),
720 }
721}
722
723pub async fn deposit_address_handler<E: Display>(
724 State(state): State<UiState<DynGatewayApi<E>>>,
725 _auth: UserAuth,
726 Form(payload): Form<DepositAddressPayload>,
727) -> impl IntoResponse {
728 let markup = match state.api.handle_deposit_address_msg(payload).await {
729 Ok(address) => {
730 let code =
731 QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
732 let qr_svg = code.render::<svg::Color>().build();
733
734 html! {
735 div class="card card-body bg-light d-flex flex-column align-items-center mt-2" {
736 span class="fw-bold mb-3" { "Deposit Address:" }
737
738 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
739
740 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
742 input type="text"
743 readonly
744 class="form-control mb-2"
745 style="text-align:left; font-family: monospace; font-size:1rem;"
746 value=(address)
747 onclick="copyToClipboard(this)"
748 {}
749 small class="text-muted" { "Click to copy" }
750 }
751
752 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
754 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
755 {
756 (PreEscaped(format!(
757 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
758 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
759 )))
760 }
761 }
762 }
763 }
764 }
765 Err(err) => {
766 html! {
767 div class="alert alert-danger mt-2" {
768 "Failed to generate deposit address: " (err)
769 }
770 }
771 }
772 };
773 Html(markup.into_string())
774}
775
776pub async fn withdraw_preview_handler<E: Display>(
779 State(state): State<UiState<DynGatewayApi<E>>>,
780 _auth: UserAuth,
781 Form(payload): Form<WithdrawPreviewPayload>,
782) -> impl IntoResponse {
783 let federation_id = payload.federation_id;
784 let is_max = matches!(payload.amount, BitcoinAmountOrAll::All);
785
786 let markup = match state.api.handle_withdraw_preview_msg(payload).await {
787 Ok(response) => {
788 let amount_label = if is_max {
789 format!("{} sats (max)", response.withdraw_amount.sats_round_down())
790 } else {
791 format!("{} sats", response.withdraw_amount.sats_round_down())
792 };
793
794 html! {
795 div class="card" {
796 div class="card-body" {
797 h6 class="card-title" { "Withdrawal Preview" }
798
799 table class="table table-sm" {
800 tbody {
801 tr {
802 td { "Amount" }
803 td { (amount_label) }
804 }
805 tr {
806 td { "Address" }
807 td class="text-break" style="font-family: monospace; font-size: 0.85em;" {
808 (response.address.clone())
809 }
810 }
811 tr {
812 td { "Fee Rate" }
813 td { (format!("{} sats/kvB", response.peg_out_fees.fee_rate.sats_per_kvb)) }
814 }
815 tr {
816 td { "Transaction Size" }
817 td { (format!("{} weight units", response.peg_out_fees.total_weight)) }
818 }
819 tr {
820 td { "Peg-out Fee" }
821 td { (format!("{} sats", response.peg_out_fees.amount().to_sat())) }
822 }
823 @if let Some(federation_fee) = response.mint_fees {
828 tr {
829 td { "Federation Fee (est.)" }
830 td { (format!("~{} sats", federation_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 { notes: notes_str };
1118
1119 let markup = match state.api.handle_receive_ecash_msg(payload).await {
1120 Ok(response) => {
1121 let (federation_id, updated_balance) = state
1123 .api
1124 .handle_get_balances_msg()
1125 .await
1126 .ok()
1127 .and_then(|balances| {
1128 balances
1129 .ecash_balances
1130 .into_iter()
1131 .find(|b| b.federation_id.to_prefix() == federation_id_prefix)
1132 .map(|b| (b.federation_id, b.ecash_balance_msats))
1133 })
1134 .expect("Federation not found");
1135
1136 let balance_class = if updated_balance == Amount::ZERO {
1137 "alert alert-danger"
1138 } else {
1139 "alert alert-success"
1140 };
1141
1142 let balance_btc = updated_balance.msats as f64 / 100_000_000_000.0;
1143
1144 html! {
1145 div class=(balance_class) {
1146 div class="d-flex justify-content-between align-items-center" {
1147 span { "Ecash received successfully!" }
1148 span class="badge bg-success" { (response.amount) }
1149 }
1150 }
1151
1152 div id=(format!("balance-{}", federation_id))
1154 class=(balance_class)
1155 data-msats=(updated_balance.msats)
1156 hx-swap-oob="true"
1157 {
1158 "Balance: " strong class="amount-display" { (format!("{:.8} BTC", balance_btc)) }
1159 }
1160 }
1161 }
1162 Err(err) => {
1163 html! {
1164 div class="alert alert-danger" {
1165 "Failed to receive ecash: " (err)
1166 }
1167 }
1168 }
1169 };
1170 Html(markup.into_string())
1171}