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