fedimint_gateway_ui/federation.rs
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 }
80 )
81}
82
83pub fn render<E: Display>(
84 fed: &FederationInfo,
85 invite_codes: &BTreeMap<PeerId, (String, InviteCode)>,
86 note_summary: &Result<TieredCounts, E>,
87) -> Markup {
88 html!(
89 @let bal = fed.balance_msat;
90 @let balance_class = if bal == Amount::ZERO {
91 "alert alert-danger"
92 } else {
93 "alert alert-success"
94 };
95 @let last_backup_str = fed.last_backup_time
96 .map(time_ago)
97 .unwrap_or("Never".to_string());
98
99
100 div class="row gy-4 mt-2" {
101 div class="col-12" {
102 div class="card h-100" {
103 div class="card-header dashboard-header d-flex justify-content-between align-items-center" {
104 div {
105 (fed.federation_name.clone().unwrap_or("Unnamed Federation".to_string()))
106 }
107
108 form method="post" action={(format!("/ui/federations/{}/leave", fed.federation_id))} {
109 button type="submit"
110 class="btn btn-outline-danger btn-sm"
111 title="Leave Federation"
112 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.');")
113 { "📤" }
114 }
115 }
116 div class="card-body" {
117 div id=(format!("balance-{}", fed.federation_id)) class=(balance_class) {
118 "Balance: " strong { (fed.balance_msat) }
119 }
120 div class="alert alert-secondary py-1 px-2 small" {
121 "Last Backup: " strong { (last_backup_str) }
122 }
123
124 // --- TABS ---
125 ul class="nav nav-tabs" role="tablist" {
126 li class="nav-item" role="presentation" {
127 button class="nav-link active"
128 id={(format!("fees-tab-{}", fed.federation_id))}
129 data-bs-toggle="tab"
130 data-bs-target={(format!("#fees-tab-pane-{}", fed.federation_id))}
131 type="button"
132 role="tab"
133 { "Fees" }
134 }
135 li class="nav-item" role="presentation" {
136 button class="nav-link"
137 id={(format!("deposit-tab-{}", fed.federation_id))}
138 data-bs-toggle="tab"
139 data-bs-target={(format!("#deposit-tab-pane-{}", fed.federation_id))}
140 type="button"
141 role="tab"
142 { "Deposit" }
143 }
144 li class="nav-item" role="presentation" {
145 button class="nav-link"
146 id={(format!("withdraw-tab-{}", fed.federation_id))}
147 data-bs-toggle="tab"
148 data-bs-target={(format!("#withdraw-tab-pane-{}", fed.federation_id))}
149 type="button"
150 role="tab"
151 { "Withdraw" }
152 }
153 li class="nav-item" role="presentation" {
154 button class="nav-link"
155 id={(format!("spend-tab-{}", fed.federation_id))}
156 data-bs-toggle="tab"
157 data-bs-target={(format!("#spend-tab-pane-{}", fed.federation_id))}
158 type="button"
159 role="tab"
160 { "Spend" }
161 }
162 li class="nav-item" role="presentation" {
163 button class="nav-link"
164 id=(format!("receive-tab-{}", fed.federation_id))
165 data-bs-toggle="tab"
166 data-bs-target=(format!("#receive-tab-pane-{}", fed.federation_id))
167 type="button"
168 role="tab"
169 { "Receive" }
170 }
171 li class="nav-item" role="presentation" {
172 button class="nav-link"
173 id=(format!("peers-tab-{}", fed.federation_id))
174 data-bs-toggle="tab"
175 data-bs-target=(format!("#peers-tab-pane-{}", fed.federation_id))
176 type="button"
177 role="tab"
178 { "Peers" }
179 }
180 li class="nav-item" role="presentation" {
181 button class="nav-link"
182 id=(format!("notes-tab-{}", fed.federation_id))
183 data-bs-toggle="tab"
184 data-bs-target=(format!("#notes-tab-pane-{}", fed.federation_id))
185 type="button"
186 role="tab"
187 { "Notes" }
188 }
189 }
190
191 div class="tab-content mt-3" {
192
193 // ──────────────────────────────────────────
194 // TAB: FEES
195 // ──────────────────────────────────────────
196 div class="tab-pane fade show active"
197 id={(format!("fees-tab-pane-{}", fed.federation_id))}
198 role="tabpanel"
199 aria-labelledby={(format!("fees-tab-{}", fed.federation_id))} {
200
201 // READ-ONLY VERSION
202 div id={(format!("fees-view-{}", fed.federation_id))} {
203 table class="table table-sm mb-2" {
204 tbody {
205 tr {
206 th {
207 "Lightning Base Fee "
208 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
209 }
210 td { (fed.config.lightning_fee.base) }
211 }
212 tr {
213 th {
214 "Lightning PPM "
215 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
216 }
217 td { (fed.config.lightning_fee.parts_per_million) }
218 }
219 tr {
220 th {
221 "Transaction Base Fee "
222 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
223 }
224 td { (fed.config.transaction_fee.base) }
225 }
226 tr {
227 th {
228 "Transaction PPM "
229 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
230 }
231 td { (fed.config.transaction_fee.parts_per_million) }
232 }
233 }
234 }
235
236 button
237 class="btn btn-sm btn-outline-primary"
238 type="button"
239 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
240 {
241 "Edit Fees"
242 }
243 }
244
245 // EDIT FORM (HIDDEN INITIALLY)
246 div id={(format!("fees-edit-{}", fed.federation_id))} style="display: none;" {
247 form
248 method="post"
249 action={(SET_FEES_ROUTE)}
250 {
251 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
252 table class="table table-sm mb-2" {
253 tbody {
254 tr {
255 th {
256 "Lightning Base Fee "
257 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
258 }
259 td {
260 input type="number"
261 class="form-control form-control-sm"
262 name="lightning_base"
263 value=(fed.config.lightning_fee.base.msats);
264 }
265 }
266 tr {
267 th {
268 "Lightning PPM "
269 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
270 }
271 td {
272 input type="number"
273 class="form-control form-control-sm"
274 name="lightning_parts_per_million"
275 value=(fed.config.lightning_fee.parts_per_million);
276 }
277 }
278 tr {
279 th {
280 "Transaction Base Fee "
281 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
282 }
283 td {
284 input type="number"
285 class="form-control form-control-sm"
286 name="transaction_base"
287 value=(fed.config.transaction_fee.base.msats);
288 }
289 }
290 tr {
291 th {
292 "Transaction PPM "
293 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
294 }
295 td {
296 input type="number"
297 class="form-control form-control-sm"
298 name="transaction_parts_per_million"
299 value=(fed.config.transaction_fee.parts_per_million);
300 }
301 }
302 }
303 }
304
305 button type="submit" class="btn btn-sm btn-primary me-2" { "Save Fees" }
306 button
307 type="button"
308 class="btn btn-sm btn-secondary"
309 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
310 {
311 "Cancel"
312 }
313 }
314 }
315 }
316
317 // ──────────────────────────────────────────
318 // TAB: DEPOSIT
319 // ──────────────────────────────────────────
320 div class="tab-pane fade"
321 id={(format!("deposit-tab-pane-{}", fed.federation_id))}
322 role="tabpanel"
323 aria-labelledby={(format!("deposit-tab-{}", fed.federation_id))} {
324
325 form hx-post=(DEPOSIT_ADDRESS_ROUTE)
326 hx-target={(format!("#deposit-result-{}", fed.federation_id))}
327 hx-swap="innerHTML"
328 {
329 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
330 button type="submit"
331 class="btn btn-outline-primary btn-sm"
332 {
333 "New Deposit Address"
334 }
335 }
336
337 div id=(format!("deposit-result-{}", fed.federation_id)) {}
338 }
339
340 // ──────────────────────────────────────────
341 // TAB: WITHDRAW
342 // ──────────────────────────────────────────
343 div class="tab-pane fade"
344 id={(format!("withdraw-tab-pane-{}", fed.federation_id))}
345 role="tabpanel"
346 aria-labelledby={(format!("withdraw-tab-{}", fed.federation_id))} {
347
348 form hx-post=(WITHDRAW_PREVIEW_ROUTE)
349 hx-target={(format!("#withdraw-result-{}", fed.federation_id))}
350 hx-swap="innerHTML"
351 class="mt-3"
352 id=(format!("withdraw-form-{}", fed.federation_id))
353 {
354 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
355
356 div class="mb-3" {
357 label class="form-label" for=(format!("withdraw-amount-{}", fed.federation_id)) { "Amount (sats or 'all')" }
358 input type="text"
359 class="form-control"
360 id=(format!("withdraw-amount-{}", fed.federation_id))
361 name="amount"
362 placeholder="e.g. 100000 or all"
363 required;
364 }
365
366 div class="mb-3" {
367 label class="form-label" for=(format!("withdraw-address-{}", fed.federation_id)) { "Bitcoin Address" }
368 input type="text"
369 class="form-control"
370 id=(format!("withdraw-address-{}", fed.federation_id))
371 name="address"
372 placeholder="bc1q..."
373 required;
374 }
375
376 button type="submit" class="btn btn-primary" { "Preview" }
377 }
378
379 div id=(format!("withdraw-result-{}", fed.federation_id)) class="mt-3" {}
380 }
381
382 // ──────────────────────────────────────────
383 // TAB: SPEND
384 // ──────────────────────────────────────────
385 div class="tab-pane fade"
386 id={(format!("spend-tab-pane-{}", fed.federation_id))}
387 role="tabpanel"
388 aria-labelledby={(format!("spend-tab-{}", fed.federation_id))} {
389
390 form hx-post=(SPEND_ECASH_ROUTE)
391 hx-target={(format!("#spend-result-{}", fed.federation_id))}
392 hx-swap="innerHTML"
393 {
394 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
395
396 // Amount input (required)
397 div class="mb-3" {
398 label class="form-label" for={(format!("spend-amount-{}", fed.federation_id))} {
399 "Amount (msats)"
400 }
401 input type="number"
402 class="form-control"
403 id={(format!("spend-amount-{}", fed.federation_id))}
404 name="amount"
405 placeholder="1000"
406 min="1"
407 required;
408 }
409
410 // Optional: allow_overpay checkbox
411 div class="form-check mb-2" {
412 input type="checkbox"
413 class="form-check-input"
414 id={(format!("spend-overpay-{}", fed.federation_id))}
415 name="allow_overpay"
416 value="true";
417 label class="form-check-label" for={(format!("spend-overpay-{}", fed.federation_id))} {
418 "Allow overpay (if exact amount unavailable)"
419 }
420 }
421
422 button type="submit" class="btn btn-primary" { "Generate Ecash" }
423 }
424
425 div id=(format!("spend-result-{}", fed.federation_id)) class="mt-3" {}
426 }
427
428 // ──────────────────────────────────────────
429 // TAB: RECEIVE
430 // ──────────────────────────────────────────
431 div class="tab-pane fade"
432 id=(format!("receive-tab-pane-{}", fed.federation_id))
433 role="tabpanel"
434 aria-labelledby=(format!("receive-tab-{}", fed.federation_id)) {
435
436 form hx-post=(RECEIVE_ECASH_ROUTE)
437 hx-target=(format!("#receive-result-{}", fed.federation_id))
438 hx-swap="innerHTML"
439 {
440 input type="hidden" name="wait" value="true";
441
442 div class="mb-3" {
443 label class="form-label" for=(format!("receive-notes-{}", fed.federation_id)) {
444 "Ecash Notes"
445 }
446 textarea
447 class="form-control font-monospace"
448 id=(format!("receive-notes-{}", fed.federation_id))
449 name="notes"
450 rows="4"
451 placeholder="Paste ecash string here..."
452 required {}
453 }
454
455 button type="submit" class="btn btn-primary" { "Receive Ecash" }
456 }
457
458 div id=(format!("receive-result-{}", fed.federation_id)) class="mt-3" {}
459 }
460
461 // ──────────────────────────────────────────
462 // TAB: PEERS
463 // ──────────────────────────────────────────
464 div class="tab-pane fade"
465 id=(format!("peers-tab-pane-{}", fed.federation_id))
466 role="tabpanel"
467 aria-labelledby=(format!("peers-tab-{}", fed.federation_id))
468 {
469 @if invite_codes.is_empty() {
470 div class="alert alert-secondary" {
471 "No invite codes found for this federation."
472 }
473 } @else {
474 table class="table table-sm" {
475 thead {
476 tr {
477 th { "Peer ID" }
478 th { "Name" }
479 th { "Invite Code" }
480 }
481 }
482 tbody {
483 @for (peer_id, (name, code)) in invite_codes {
484 @let code_str = code.to_string();
485 @let modal_id = format!("qr-modal-{}-{}", fed.federation_id, peer_id);
486 @let qr = QrCode::new(code_str.as_bytes()).expect("Failed to generate QR code");
487 @let qr_svg = qr.render::<svg::Color>().build();
488 tr {
489 td { (peer_id) }
490 td { (name) }
491 td {
492 div class="d-flex align-items-center gap-1" {
493 input type="text"
494 class="form-control form-control-sm"
495 value=(code_str)
496 readonly
497 onclick="copyText(this)"
498 style="cursor: pointer; font-size: 0.75rem;";
499 button type="button"
500 class="btn btn-sm btn-outline-secondary"
501 data-bs-toggle="modal"
502 data-bs-target=(format!("#{}", modal_id))
503 title="Show QR Code"
504 { "QR" }
505 }
506
507 // QR Code Modal
508 div class="modal fade"
509 id=(modal_id)
510 tabindex="-1"
511 aria-hidden="true"
512 {
513 div class="modal-dialog modal-dialog-centered" {
514 div class="modal-content" {
515 div class="modal-header" {
516 h5 class="modal-title" {
517 "Invite Code — Peer " (peer_id) " (" (name) ")"
518 }
519 button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" {}
520 }
521 div class="modal-body d-flex justify-content-center" {
522 div class="border rounded p-2 bg-white"
523 style="width: 300px; height: 300px;"
524 {
525 (PreEscaped(format!(
526 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
527 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
528 )))
529 }
530 }
531 }
532 }
533 }
534 }
535 }
536 }
537 }
538 }
539 }
540 }
541
542 // ──────────────────────────────────────────
543 // TAB: NOTES
544 // ──────────────────────────────────────────
545 div class="tab-pane fade"
546 id=(format!("notes-tab-pane-{}", fed.federation_id))
547 role="tabpanel"
548 aria-labelledby=(format!("notes-tab-{}", fed.federation_id))
549 {
550 @match ¬e_summary {
551 Ok(notes) => {
552 @if notes.is_empty() {
553 div class="alert alert-secondary" {
554 "No notes in wallet."
555 }
556 } @else {
557 table class="table table-sm table-bordered mb-0" {
558 thead {
559 tr class="table-light" {
560 th { "Denomination" }
561 th { "Count" }
562 th { "Subtotal" }
563 }
564 }
565 tbody {
566 @for (denomination, count) in notes.iter() {
567 tr {
568 td { (denomination) }
569 td { (count) }
570 td { (denomination * (count as u64)) }
571 }
572 }
573 }
574 tfoot {
575 tr class="table-light fw-bold" {
576 td { "Total" }
577 td { (notes.count_items()) }
578 td { (notes.total_amount()) }
579 }
580 }
581 }
582 }
583 }
584 Err(err) => {
585 div class="alert alert-warning" {
586 "Could not load note summary: " (err)
587 }
588 }
589 }
590 }
591 }
592 }
593 }
594 }
595 }
596 )
597}
598
599fn time_ago(t: SystemTime) -> String {
600 let now = fedimint_core::time::now();
601 let diff = match now.duration_since(t) {
602 Ok(d) => d,
603 Err(_) => Duration::from_secs(0),
604 };
605
606 let secs = diff.as_secs();
607
608 match secs {
609 0..=59 => format!("{} seconds ago", secs),
610 60..=3599 => format!("{} minutes ago", secs / 60),
611 _ => format!("{} hours ago", secs / 3600),
612 }
613}
614
615pub async fn leave_federation_handler<E: Display>(
616 State(state): State<UiState<DynGatewayApi<E>>>,
617 Path(id): Path<String>,
618 _auth: UserAuth,
619) -> impl IntoResponse {
620 let federation_id = FederationId::from_str(&id);
621 if let Ok(federation_id) = federation_id {
622 match state
623 .api
624 .handle_leave_federation(LeaveFedPayload { federation_id })
625 .await
626 {
627 Ok(info) => {
628 // Redirect back to dashboard after success
629 redirect_success_with_export_reminder(format!(
630 "Successfully left {}.",
631 info.federation_name
632 .unwrap_or("Unnamed Federation".to_string())
633 ))
634 .into_response()
635 }
636 Err(err) => {
637 redirect_error(format!("Failed to leave federation: {err}")).into_response()
638 }
639 }
640 } else {
641 redirect_error("Failed to leave federation: Invalid federation id".to_string())
642 .into_response()
643 }
644}
645
646pub async fn set_fees_handler<E: Display>(
647 State(state): State<UiState<DynGatewayApi<E>>>,
648 _auth: UserAuth,
649 Form(payload): Form<SetFeesPayload>,
650) -> impl IntoResponse {
651 tracing::info!("Received fees payload: {:?}", payload);
652
653 match state.api.handle_set_fees_msg(payload).await {
654 Ok(_) => redirect_success("Successfully set fees".to_string()).into_response(),
655 Err(err) => redirect_error(format!("Failed to update fees: {err}")).into_response(),
656 }
657}
658
659pub async fn deposit_address_handler<E: Display>(
660 State(state): State<UiState<DynGatewayApi<E>>>,
661 _auth: UserAuth,
662 Form(payload): Form<DepositAddressPayload>,
663) -> impl IntoResponse {
664 let markup = match state.api.handle_deposit_address_msg(payload).await {
665 Ok(address) => {
666 let code =
667 QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
668 let qr_svg = code.render::<svg::Color>().build();
669
670 html! {
671 div class="card card-body bg-light d-flex flex-column align-items-center mt-2" {
672 span class="fw-bold mb-3" { "Deposit Address:" }
673
674 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
675
676 // Copyable input + text
677 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
678 input type="text"
679 readonly
680 class="form-control mb-2"
681 style="text-align:left; font-family: monospace; font-size:1rem;"
682 value=(address)
683 onclick="copyToClipboard(this)"
684 {}
685 small class="text-muted" { "Click to copy" }
686 }
687
688 // QR code
689 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
690 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
691 {
692 (PreEscaped(format!(
693 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
694 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
695 )))
696 }
697 }
698 }
699 }
700 }
701 Err(err) => {
702 html! {
703 div class="alert alert-danger mt-2" {
704 "Failed to generate deposit address: " (err)
705 }
706 }
707 }
708 };
709 Html(markup.into_string())
710}
711
712/// Preview handler for two-step withdrawal flow - shows fee breakdown before
713/// confirmation
714pub async fn withdraw_preview_handler<E: Display>(
715 State(state): State<UiState<DynGatewayApi<E>>>,
716 _auth: UserAuth,
717 Form(payload): Form<WithdrawPreviewPayload>,
718) -> impl IntoResponse {
719 let federation_id = payload.federation_id;
720 let is_max = matches!(payload.amount, BitcoinAmountOrAll::All);
721
722 let markup = match state.api.handle_withdraw_preview_msg(payload).await {
723 Ok(response) => {
724 let amount_label = if is_max {
725 format!("{} sats (max)", response.withdraw_amount.sats_round_down())
726 } else {
727 format!("{} sats", response.withdraw_amount.sats_round_down())
728 };
729
730 html! {
731 div class="card" {
732 div class="card-body" {
733 h6 class="card-title" { "Withdrawal Preview" }
734
735 table class="table table-sm" {
736 tbody {
737 tr {
738 td { "Amount" }
739 td { (amount_label) }
740 }
741 tr {
742 td { "Address" }
743 td class="text-break" style="font-family: monospace; font-size: 0.85em;" {
744 (response.address.clone())
745 }
746 }
747 tr {
748 td { "Fee Rate" }
749 td { (format!("{} sats/kvB", response.peg_out_fees.fee_rate.sats_per_kvb)) }
750 }
751 tr {
752 td { "Transaction Size" }
753 td { (format!("{} weight units", response.peg_out_fees.total_weight)) }
754 }
755 tr {
756 td { "Peg-out Fee" }
757 td { (format!("{} sats", response.peg_out_fees.amount().to_sat())) }
758 }
759 @if let Some(mint_fee) = response.mint_fees {
760 tr {
761 td { "Mint Fee (est.)" }
762 td { (format!("~{} sats", mint_fee.sats_round_down())) }
763 }
764 }
765 tr {
766 td { strong { "Total Deducted" } }
767 td { strong { (format!("{} sats", response.total_cost.sats_round_down())) } }
768 }
769 }
770 }
771
772 div class="d-flex gap-2 mt-3" {
773 // Confirm form with hidden fields
774 form hx-post=(WITHDRAW_CONFIRM_ROUTE)
775 hx-target=(format!("#withdraw-result-{}", federation_id))
776 hx-swap="innerHTML"
777 {
778 input type="hidden" name="federation_id" value=(federation_id.to_string());
779 input type="hidden" name="amount" value=(response.withdraw_amount.sats_round_down().to_string());
780 input type="hidden" name="address" value=(response.address);
781 input type="hidden" name="fee_rate_sats_per_kvb" value=(response.peg_out_fees.fee_rate.sats_per_kvb.to_string());
782 input type="hidden" name="total_weight" value=(response.peg_out_fees.total_weight.to_string());
783
784 button type="submit" class="btn btn-success" { "Confirm Withdrawal" }
785 }
786
787 // Cancel button - clears the result area
788 button type="button"
789 class="btn btn-outline-secondary"
790 onclick=(format!("document.getElementById('withdraw-result-{}').innerHTML = ''", federation_id))
791 { "Cancel" }
792 }
793 }
794 }
795 }
796 }
797 Err(err) => {
798 html! {
799 div class="alert alert-danger" {
800 "Error: " (err.to_string())
801 }
802 }
803 }
804 };
805 Html(markup.into_string())
806}
807
808/// Payload for withdraw confirmation from the UI
809#[derive(Debug, serde::Deserialize)]
810pub struct WithdrawConfirmPayload {
811 pub federation_id: FederationId,
812 pub amount: u64,
813 pub address: String,
814 pub fee_rate_sats_per_kvb: u64,
815 pub total_weight: u64,
816}
817
818/// Confirm handler for two-step withdrawal flow - executes withdrawal with
819/// quoted fees
820pub async fn withdraw_confirm_handler<E: Display>(
821 State(state): State<UiState<DynGatewayApi<E>>>,
822 _auth: UserAuth,
823 Form(payload): Form<WithdrawConfirmPayload>,
824) -> impl IntoResponse {
825 let federation_id = payload.federation_id;
826
827 // Parse the address - it should already be validated from the preview step
828 let address: Address<NetworkUnchecked> = match payload.address.parse() {
829 Ok(addr) => addr,
830 Err(err) => {
831 return Html(
832 html! {
833 div class="alert alert-danger" {
834 "Error parsing address: " (err.to_string())
835 }
836 }
837 .into_string(),
838 );
839 }
840 };
841
842 // Build the WithdrawPayload with the quoted fees
843 let withdraw_payload = WithdrawPayload {
844 federation_id,
845 amount: BitcoinAmountOrAll::Amount(bitcoin::Amount::from_sat(payload.amount)),
846 address,
847 quoted_fees: Some(PegOutFees::new(
848 payload.fee_rate_sats_per_kvb,
849 payload.total_weight,
850 )),
851 };
852
853 let markup = match state.api.handle_withdraw_msg(withdraw_payload).await {
854 Ok(response) => {
855 // Fetch updated balance for the out-of-band swap
856 let updated_balance = state
857 .api
858 .handle_get_balances_msg()
859 .await
860 .ok()
861 .and_then(|balances| {
862 balances
863 .ecash_balances
864 .into_iter()
865 .find(|b| b.federation_id == federation_id)
866 .map(|b| b.ecash_balance_msats)
867 })
868 .unwrap_or(Amount::ZERO);
869
870 let balance_class = if updated_balance == Amount::ZERO {
871 "alert alert-danger"
872 } else {
873 "alert alert-success"
874 };
875
876 html! {
877 // Success message (swaps into result div)
878 div class="alert alert-success" {
879 p { strong { "Withdrawal successful!" } }
880 p { "Transaction ID: " code { (response.txid) } }
881 p { "Peg-out Fee: " (format!("{} sats", response.fees.amount().to_sat())) }
882 }
883
884 // Out-of-band swap to update balance banner
885 div id=(format!("balance-{}", federation_id))
886 class=(balance_class)
887 hx-swap-oob="true"
888 {
889 "Balance: " strong { (updated_balance) }
890 }
891 }
892 }
893 Err(err) => {
894 html! {
895 div class="alert alert-danger" {
896 "Error: " (err.to_string())
897 }
898 }
899 }
900 };
901 Html(markup.into_string())
902}
903
904pub async fn spend_ecash_handler<E: Display>(
905 State(state): State<UiState<DynGatewayApi<E>>>,
906 _auth: UserAuth,
907 Form(payload): Form<SpendEcashPayload>,
908) -> impl IntoResponse {
909 let federation_id = payload.federation_id;
910 let requested_amount = payload.amount;
911
912 // Always include the federation invite in the ecash notes
913 let mut payload = payload;
914 payload.include_invite = true;
915
916 let markup = match state.api.handle_spend_ecash_msg(payload).await {
917 Ok(response) => {
918 let notes_string = response.notes.to_string();
919 let actual_amount = response.notes.total_amount();
920 let overspent = actual_amount > requested_amount;
921
922 // Fetch updated balance for the out-of-band swap
923 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 html! {
944 div class="card card-body bg-light" {
945 div class="d-flex justify-content-between align-items-center mb-2" {
946 span class="fw-bold" { "Ecash Generated" }
947 span class="badge bg-success" { (actual_amount) }
948 }
949
950 @if overspent {
951 div class="alert alert-warning py-2 mb-2" {
952 "Note: Spent " (actual_amount) " ("
953 (actual_amount.saturating_sub(requested_amount))
954 " more than requested due to note denominations)"
955 }
956 }
957
958 div class="mb-2" {
959 label class="form-label small text-muted" { "Ecash Notes (click to copy):" }
960 textarea
961 class="form-control font-monospace"
962 rows="4"
963 readonly
964 onclick="copyToClipboard(this)"
965 style="font-size: 0.85rem;"
966 { (notes_string) }
967 small class="text-muted" { "Click to copy" }
968 }
969 }
970
971 // Out-of-band swap to update balance banner
972 div id=(format!("balance-{}", federation_id))
973 class=(balance_class)
974 hx-swap-oob="true"
975 {
976 "Balance: " strong { (updated_balance) }
977 }
978 }
979 }
980 Err(err) => {
981 html! {
982 div class="alert alert-danger" {
983 "Failed to generate ecash: " (err)
984 }
985 }
986 }
987 };
988 Html(markup.into_string())
989}
990
991pub async fn receive_ecash_handler<E: Display>(
992 State(state): State<UiState<DynGatewayApi<E>>>,
993 _auth: UserAuth,
994 Form(form): Form<ReceiveEcashForm>,
995) -> impl IntoResponse {
996 // Parse the notes string manually to provide better error messages
997 let notes = match form.notes.trim().parse::<OOBNotes>() {
998 Ok(n) => n,
999 Err(e) => {
1000 return Html(
1001 html! {
1002 div class="alert alert-danger" {
1003 "Invalid ecash format: " (e)
1004 }
1005 }
1006 .into_string(),
1007 );
1008 }
1009 };
1010
1011 // Construct payload from parsed notes
1012 let payload = ReceiveEcashPayload {
1013 notes,
1014 wait: form.wait,
1015 };
1016
1017 // Extract federation_id_prefix from notes before consuming payload
1018 let federation_id_prefix = payload.notes.federation_id_prefix();
1019
1020 let markup = match state.api.handle_receive_ecash_msg(payload).await {
1021 Ok(response) => {
1022 // Fetch updated balance for oob swap
1023 let (federation_id, updated_balance) = state
1024 .api
1025 .handle_get_balances_msg()
1026 .await
1027 .ok()
1028 .and_then(|balances| {
1029 balances
1030 .ecash_balances
1031 .into_iter()
1032 .find(|b| b.federation_id.to_prefix() == federation_id_prefix)
1033 .map(|b| (b.federation_id, b.ecash_balance_msats))
1034 })
1035 .expect("Federation not found");
1036
1037 let balance_class = if updated_balance == Amount::ZERO {
1038 "alert alert-danger"
1039 } else {
1040 "alert alert-success"
1041 };
1042
1043 html! {
1044 div class=(balance_class) {
1045 div class="d-flex justify-content-between align-items-center" {
1046 span { "Ecash received successfully!" }
1047 span class="badge bg-success" { (response.amount) }
1048 }
1049 }
1050
1051 // Out-of-band swap to update balance banner
1052 div id=(format!("balance-{}", federation_id))
1053 class=(balance_class)
1054 hx-swap-oob="true"
1055 {
1056 "Balance: " strong { (updated_balance) }
1057 }
1058 }
1059 }
1060 Err(err) => {
1061 html! {
1062 div class="alert alert-danger" {
1063 "Failed to receive ecash: " (err)
1064 }
1065 }
1066 }
1067 };
1068 Html(markup.into_string())
1069}