1use std::fmt::Display;
2use std::str::FromStr;
3use std::time::{Duration, SystemTime};
4
5use axum::Form;
6use axum::extract::{Path, State};
7use axum::response::{Html, IntoResponse};
8use bitcoin::Address;
9use bitcoin::address::NetworkUnchecked;
10use fedimint_core::config::FederationId;
11use fedimint_core::{Amount, BitcoinAmountOrAll};
12use fedimint_gateway_common::{
13 DepositAddressPayload, FederationInfo, LeaveFedPayload, ReceiveEcashPayload, SetFeesPayload,
14 SpendEcashPayload, WithdrawPayload, WithdrawPreviewPayload,
15};
16use fedimint_mint_client::OOBNotes;
17use fedimint_ui_common::UiState;
18use fedimint_ui_common::auth::UserAuth;
19use fedimint_wallet_client::PegOutFees;
20use maud::{Markup, PreEscaped, html};
21use qrcode::QrCode;
22use qrcode::render::svg;
23use serde::Deserialize;
24
25use crate::{
26 DEPOSIT_ADDRESS_ROUTE, DynGatewayApi, RECEIVE_ECASH_ROUTE, SET_FEES_ROUTE, SPEND_ECASH_ROUTE,
27 WITHDRAW_CONFIRM_ROUTE, WITHDRAW_PREVIEW_ROUTE, redirect_error, redirect_success,
28};
29
30#[derive(Deserialize)]
31pub struct ReceiveEcashForm {
32 pub notes: String,
33 #[serde(default)]
34 pub wait: bool,
35}
36
37pub fn scripts() -> Markup {
38 html!(
39 script {
40 (PreEscaped(r#"
41 function toggleFeesEdit(id) {
42 const viewDiv = document.getElementById('fees-view-' + id);
43 const editDiv = document.getElementById('fees-edit-' + id);
44 if (viewDiv.style.display === 'none') {
45 viewDiv.style.display = '';
46 editDiv.style.display = 'none';
47 } else {
48 viewDiv.style.display = 'none';
49 editDiv.style.display = '';
50 }
51 }
52
53 function copyToClipboard(input) {
54 input.select();
55 document.execCommand('copy');
56 const hint = input.nextElementSibling;
57 hint.textContent = 'Copied!';
58 setTimeout(() => hint.textContent = 'Click to copy', 2000);
59 }
60
61 // Initialize Bootstrap tooltips
62 document.addEventListener('DOMContentLoaded', function() {
63 var tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
64 tooltipTriggerList.forEach(function(el) {
65 new bootstrap.Tooltip(el);
66 });
67 });
68 "#))
69 }
70 )
71}
72
73pub fn render(fed: &FederationInfo) -> Markup {
74 html!(
75 @let bal = fed.balance_msat;
76 @let balance_class = if bal == Amount::ZERO {
77 "alert alert-danger"
78 } else {
79 "alert alert-success"
80 };
81 @let last_backup_str = fed.last_backup_time
82 .map(time_ago)
83 .unwrap_or("Never".to_string());
84
85
86 div class="row gy-4 mt-2" {
87 div class="col-12" {
88 div class="card h-100" {
89 div class="card-header dashboard-header d-flex justify-content-between align-items-center" {
90 div {
91 (fed.federation_name.clone().unwrap_or("Unnamed Federation".to_string()))
92 }
93
94 form method="post" action={(format!("/ui/federations/{}/leave", fed.federation_id))} {
95 button type="submit"
96 class="btn btn-outline-danger btn-sm"
97 title="Leave Federation"
98 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.');")
99 { "📤" }
100 }
101 }
102 div class="card-body" {
103 div id=(format!("balance-{}", fed.federation_id)) class=(balance_class) {
104 "Balance: " strong { (fed.balance_msat) }
105 }
106 div class="alert alert-secondary py-1 px-2 small" {
107 "Last Backup: " strong { (last_backup_str) }
108 }
109
110 ul class="nav nav-tabs" role="tablist" {
112 li class="nav-item" role="presentation" {
113 button class="nav-link active"
114 id={(format!("fees-tab-{}", fed.federation_id))}
115 data-bs-toggle="tab"
116 data-bs-target={(format!("#fees-tab-pane-{}", fed.federation_id))}
117 type="button"
118 role="tab"
119 { "Fees" }
120 }
121 li class="nav-item" role="presentation" {
122 button class="nav-link"
123 id={(format!("deposit-tab-{}", fed.federation_id))}
124 data-bs-toggle="tab"
125 data-bs-target={(format!("#deposit-tab-pane-{}", fed.federation_id))}
126 type="button"
127 role="tab"
128 { "Deposit" }
129 }
130 li class="nav-item" role="presentation" {
131 button class="nav-link"
132 id={(format!("withdraw-tab-{}", fed.federation_id))}
133 data-bs-toggle="tab"
134 data-bs-target={(format!("#withdraw-tab-pane-{}", fed.federation_id))}
135 type="button"
136 role="tab"
137 { "Withdraw" }
138 }
139 li class="nav-item" role="presentation" {
140 button class="nav-link"
141 id={(format!("spend-tab-{}", fed.federation_id))}
142 data-bs-toggle="tab"
143 data-bs-target={(format!("#spend-tab-pane-{}", fed.federation_id))}
144 type="button"
145 role="tab"
146 { "Spend" }
147 }
148 li class="nav-item" role="presentation" {
149 button class="nav-link"
150 id=(format!("receive-tab-{}", fed.federation_id))
151 data-bs-toggle="tab"
152 data-bs-target=(format!("#receive-tab-pane-{}", fed.federation_id))
153 type="button"
154 role="tab"
155 { "Receive" }
156 }
157 }
158
159 div class="tab-content mt-3" {
160
161 div class="tab-pane fade show active"
165 id={(format!("fees-tab-pane-{}", fed.federation_id))}
166 role="tabpanel"
167 aria-labelledby={(format!("fees-tab-{}", fed.federation_id))} {
168
169 div id={(format!("fees-view-{}", fed.federation_id))} {
171 table class="table table-sm mb-2" {
172 tbody {
173 tr {
174 th {
175 "Lightning Base Fee "
176 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
177 }
178 td { (fed.config.lightning_fee.base) }
179 }
180 tr {
181 th {
182 "Lightning PPM "
183 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
184 }
185 td { (fed.config.lightning_fee.parts_per_million) }
186 }
187 tr {
188 th {
189 "Transaction Base Fee "
190 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
191 }
192 td { (fed.config.transaction_fee.base) }
193 }
194 tr {
195 th {
196 "Transaction PPM "
197 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
198 }
199 td { (fed.config.transaction_fee.parts_per_million) }
200 }
201 }
202 }
203
204 button
205 class="btn btn-sm btn-outline-primary"
206 type="button"
207 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
208 {
209 "Edit Fees"
210 }
211 }
212
213 div id={(format!("fees-edit-{}", fed.federation_id))} style="display: none;" {
215 form
216 method="post"
217 action={(SET_FEES_ROUTE)}
218 {
219 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
220 table class="table table-sm mb-2" {
221 tbody {
222 tr {
223 th {
224 "Lightning Base Fee "
225 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis charged for outgoing Lightning payments" { "ⓘ" }
226 }
227 td {
228 input type="number"
229 class="form-control form-control-sm"
230 name="lightning_base"
231 value=(fed.config.lightning_fee.base.msats);
232 }
233 }
234 tr {
235 th {
236 "Lightning PPM "
237 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) of outgoing Lightning payment amounts" { "ⓘ" }
238 }
239 td {
240 input type="number"
241 class="form-control form-control-sm"
242 name="lightning_parts_per_million"
243 value=(fed.config.lightning_fee.parts_per_million);
244 }
245 }
246 tr {
247 th {
248 "Transaction Base Fee "
249 span class="text-muted" data-bs-toggle="tooltip" title="Fixed fee in millisatoshis to cover the transaction fees charged by the federation" { "ⓘ" }
250 }
251 td {
252 input type="number"
253 class="form-control form-control-sm"
254 name="transaction_base"
255 value=(fed.config.transaction_fee.base.msats);
256 }
257 }
258 tr {
259 th {
260 "Transaction PPM "
261 span class="text-muted" data-bs-toggle="tooltip" title="Variable fee in parts per million (0.0001%) to cover the federation's transaction fees" { "ⓘ" }
262 }
263 td {
264 input type="number"
265 class="form-control form-control-sm"
266 name="transaction_parts_per_million"
267 value=(fed.config.transaction_fee.parts_per_million);
268 }
269 }
270 }
271 }
272
273 button type="submit" class="btn btn-sm btn-primary me-2" { "Save Fees" }
274 button
275 type="button"
276 class="btn btn-sm btn-secondary"
277 onclick={(format!("toggleFeesEdit('{}')", fed.federation_id))}
278 {
279 "Cancel"
280 }
281 }
282 }
283 }
284
285 div class="tab-pane fade"
289 id={(format!("deposit-tab-pane-{}", fed.federation_id))}
290 role="tabpanel"
291 aria-labelledby={(format!("deposit-tab-{}", fed.federation_id))} {
292
293 form hx-post=(DEPOSIT_ADDRESS_ROUTE)
294 hx-target={(format!("#deposit-result-{}", fed.federation_id))}
295 hx-swap="innerHTML"
296 {
297 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
298 button type="submit"
299 class="btn btn-outline-primary btn-sm"
300 {
301 "New Deposit Address"
302 }
303 }
304
305 div id=(format!("deposit-result-{}", fed.federation_id)) {}
306 }
307
308 div class="tab-pane fade"
312 id={(format!("withdraw-tab-pane-{}", fed.federation_id))}
313 role="tabpanel"
314 aria-labelledby={(format!("withdraw-tab-{}", fed.federation_id))} {
315
316 form hx-post=(WITHDRAW_PREVIEW_ROUTE)
317 hx-target={(format!("#withdraw-result-{}", fed.federation_id))}
318 hx-swap="innerHTML"
319 class="mt-3"
320 id=(format!("withdraw-form-{}", fed.federation_id))
321 {
322 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
323
324 div class="mb-3" {
325 label class="form-label" for=(format!("withdraw-amount-{}", fed.federation_id)) { "Amount (sats or 'all')" }
326 input type="text"
327 class="form-control"
328 id=(format!("withdraw-amount-{}", fed.federation_id))
329 name="amount"
330 placeholder="e.g. 100000 or all"
331 required;
332 }
333
334 div class="mb-3" {
335 label class="form-label" for=(format!("withdraw-address-{}", fed.federation_id)) { "Bitcoin Address" }
336 input type="text"
337 class="form-control"
338 id=(format!("withdraw-address-{}", fed.federation_id))
339 name="address"
340 placeholder="bc1q..."
341 required;
342 }
343
344 button type="submit" class="btn btn-primary" { "Preview" }
345 }
346
347 div id=(format!("withdraw-result-{}", fed.federation_id)) class="mt-3" {}
348 }
349
350 div class="tab-pane fade"
354 id={(format!("spend-tab-pane-{}", fed.federation_id))}
355 role="tabpanel"
356 aria-labelledby={(format!("spend-tab-{}", fed.federation_id))} {
357
358 form hx-post=(SPEND_ECASH_ROUTE)
359 hx-target={(format!("#spend-result-{}", fed.federation_id))}
360 hx-swap="innerHTML"
361 {
362 input type="hidden" name="federation_id" value=(fed.federation_id.to_string());
363
364 div class="mb-3" {
366 label class="form-label" for={(format!("spend-amount-{}", fed.federation_id))} {
367 "Amount (msats)"
368 }
369 input type="number"
370 class="form-control"
371 id={(format!("spend-amount-{}", fed.federation_id))}
372 name="amount"
373 placeholder="1000"
374 min="1"
375 required;
376 }
377
378 div class="form-check mb-2" {
380 input type="checkbox"
381 class="form-check-input"
382 id={(format!("spend-overpay-{}", fed.federation_id))}
383 name="allow_overpay"
384 value="true";
385 label class="form-check-label" for={(format!("spend-overpay-{}", fed.federation_id))} {
386 "Allow overpay (if exact amount unavailable)"
387 }
388 }
389
390 button type="submit" class="btn btn-primary" { "Generate Ecash" }
391 }
392
393 div id=(format!("spend-result-{}", fed.federation_id)) class="mt-3" {}
394 }
395
396 div class="tab-pane fade"
400 id=(format!("receive-tab-pane-{}", fed.federation_id))
401 role="tabpanel"
402 aria-labelledby=(format!("receive-tab-{}", fed.federation_id)) {
403
404 form hx-post=(RECEIVE_ECASH_ROUTE)
405 hx-target=(format!("#receive-result-{}", fed.federation_id))
406 hx-swap="innerHTML"
407 {
408 input type="hidden" name="wait" value="true";
409
410 div class="mb-3" {
411 label class="form-label" for=(format!("receive-notes-{}", fed.federation_id)) {
412 "Ecash Notes"
413 }
414 textarea
415 class="form-control font-monospace"
416 id=(format!("receive-notes-{}", fed.federation_id))
417 name="notes"
418 rows="4"
419 placeholder="Paste ecash string here..."
420 required {}
421 }
422
423 button type="submit" class="btn btn-primary" { "Receive Ecash" }
424 }
425
426 div id=(format!("receive-result-{}", fed.federation_id)) class="mt-3" {}
427 }
428 }
429 }
430 }
431 }
432 }
433 )
434}
435
436fn time_ago(t: SystemTime) -> String {
437 let now = fedimint_core::time::now();
438 let diff = match now.duration_since(t) {
439 Ok(d) => d,
440 Err(_) => Duration::from_secs(0),
441 };
442
443 let secs = diff.as_secs();
444
445 match secs {
446 0..=59 => format!("{} seconds ago", secs),
447 60..=3599 => format!("{} minutes ago", secs / 60),
448 _ => format!("{} hours ago", secs / 3600),
449 }
450}
451
452pub async fn leave_federation_handler<E: Display>(
453 State(state): State<UiState<DynGatewayApi<E>>>,
454 Path(id): Path<String>,
455 _auth: UserAuth,
456) -> impl IntoResponse {
457 let federation_id = FederationId::from_str(&id);
458 if let Ok(federation_id) = federation_id {
459 match state
460 .api
461 .handle_leave_federation(LeaveFedPayload { federation_id })
462 .await
463 {
464 Ok(info) => {
465 redirect_success(format!(
467 "Successfully left {}",
468 info.federation_name
469 .unwrap_or("Unnamed Federation".to_string())
470 ))
471 .into_response()
472 }
473 Err(err) => {
474 redirect_error(format!("Failed to leave federation: {err}")).into_response()
475 }
476 }
477 } else {
478 redirect_error("Failed to leave federation: Invalid federation id".to_string())
479 .into_response()
480 }
481}
482
483pub async fn set_fees_handler<E: Display>(
484 State(state): State<UiState<DynGatewayApi<E>>>,
485 _auth: UserAuth,
486 Form(payload): Form<SetFeesPayload>,
487) -> impl IntoResponse {
488 tracing::info!("Received fees payload: {:?}", payload);
489
490 match state.api.handle_set_fees_msg(payload).await {
491 Ok(_) => redirect_success("Successfully set fees".to_string()).into_response(),
492 Err(err) => redirect_error(format!("Failed to update fees: {err}")).into_response(),
493 }
494}
495
496pub async fn deposit_address_handler<E: Display>(
497 State(state): State<UiState<DynGatewayApi<E>>>,
498 _auth: UserAuth,
499 Form(payload): Form<DepositAddressPayload>,
500) -> impl IntoResponse {
501 let markup = match state.api.handle_deposit_address_msg(payload).await {
502 Ok(address) => {
503 let code =
504 QrCode::new(address.to_qr_uri().as_bytes()).expect("Failed to generate QR code");
505 let qr_svg = code.render::<svg::Color>().build();
506
507 html! {
508 div class="card card-body bg-light d-flex flex-column align-items-center mt-2" {
509 span class="fw-bold mb-3" { "Deposit Address:" }
510
511 div class="d-flex flex-row align-items-center gap-3 flex-wrap" style="width: 100%;" {
512
513 div class="d-flex flex-column flex-grow-1" style="min-width: 300px;" {
515 input type="text"
516 readonly
517 class="form-control mb-2"
518 style="text-align:left; font-family: monospace; font-size:1rem;"
519 value=(address)
520 onclick="copyToClipboard(this)"
521 {}
522 small class="text-muted" { "Click to copy" }
523 }
524
525 div class="border rounded p-2 bg-white d-flex justify-content-center align-items-center"
527 style="width: 300px; height: 300px; min-width: 200px; min-height: 200px;"
528 {
529 (PreEscaped(format!(
530 r#"<svg style="width: 100%; height: 100%; display: block;">{}</svg>"#,
531 qr_svg.replace("width=", "data-width=").replace("height=", "data-height=")
532 )))
533 }
534 }
535 }
536 }
537 }
538 Err(err) => {
539 html! {
540 div class="alert alert-danger mt-2" {
541 "Failed to generate deposit address: " (err)
542 }
543 }
544 }
545 };
546 Html(markup.into_string())
547}
548
549pub async fn withdraw_preview_handler<E: Display>(
552 State(state): State<UiState<DynGatewayApi<E>>>,
553 _auth: UserAuth,
554 Form(payload): Form<WithdrawPreviewPayload>,
555) -> impl IntoResponse {
556 let federation_id = payload.federation_id;
557 let is_max = matches!(payload.amount, BitcoinAmountOrAll::All);
558
559 let markup = match state.api.handle_withdraw_preview_msg(payload).await {
560 Ok(response) => {
561 let amount_label = if is_max {
562 format!("{} sats (max)", response.withdraw_amount.sats_round_down())
563 } else {
564 format!("{} sats", response.withdraw_amount.sats_round_down())
565 };
566
567 html! {
568 div class="card" {
569 div class="card-body" {
570 h6 class="card-title" { "Withdrawal Preview" }
571
572 table class="table table-sm" {
573 tbody {
574 tr {
575 td { "Amount" }
576 td { (amount_label) }
577 }
578 tr {
579 td { "Address" }
580 td class="text-break" style="font-family: monospace; font-size: 0.85em;" {
581 (response.address.clone())
582 }
583 }
584 tr {
585 td { "Fee Rate" }
586 td { (format!("{} sats/kvB", response.peg_out_fees.fee_rate.sats_per_kvb)) }
587 }
588 tr {
589 td { "Transaction Size" }
590 td { (format!("{} weight units", response.peg_out_fees.total_weight)) }
591 }
592 tr {
593 td { "Peg-out Fee" }
594 td { (format!("{} sats", response.peg_out_fees.amount().to_sat())) }
595 }
596 @if let Some(mint_fee) = response.mint_fees {
597 tr {
598 td { "Mint Fee (est.)" }
599 td { (format!("~{} sats", mint_fee.sats_round_down())) }
600 }
601 }
602 tr {
603 td { strong { "Total Deducted" } }
604 td { strong { (format!("{} sats", response.total_cost.sats_round_down())) } }
605 }
606 }
607 }
608
609 div class="d-flex gap-2 mt-3" {
610 form hx-post=(WITHDRAW_CONFIRM_ROUTE)
612 hx-target=(format!("#withdraw-result-{}", federation_id))
613 hx-swap="innerHTML"
614 {
615 input type="hidden" name="federation_id" value=(federation_id.to_string());
616 input type="hidden" name="amount" value=(response.withdraw_amount.sats_round_down().to_string());
617 input type="hidden" name="address" value=(response.address);
618 input type="hidden" name="fee_rate_sats_per_kvb" value=(response.peg_out_fees.fee_rate.sats_per_kvb.to_string());
619 input type="hidden" name="total_weight" value=(response.peg_out_fees.total_weight.to_string());
620
621 button type="submit" class="btn btn-success" { "Confirm Withdrawal" }
622 }
623
624 button type="button"
626 class="btn btn-outline-secondary"
627 onclick=(format!("document.getElementById('withdraw-result-{}').innerHTML = ''", federation_id))
628 { "Cancel" }
629 }
630 }
631 }
632 }
633 }
634 Err(err) => {
635 html! {
636 div class="alert alert-danger" {
637 "Error: " (err.to_string())
638 }
639 }
640 }
641 };
642 Html(markup.into_string())
643}
644
645#[derive(Debug, serde::Deserialize)]
647pub struct WithdrawConfirmPayload {
648 pub federation_id: FederationId,
649 pub amount: u64,
650 pub address: String,
651 pub fee_rate_sats_per_kvb: u64,
652 pub total_weight: u64,
653}
654
655pub async fn withdraw_confirm_handler<E: Display>(
658 State(state): State<UiState<DynGatewayApi<E>>>,
659 _auth: UserAuth,
660 Form(payload): Form<WithdrawConfirmPayload>,
661) -> impl IntoResponse {
662 let federation_id = payload.federation_id;
663
664 let address: Address<NetworkUnchecked> = match payload.address.parse() {
666 Ok(addr) => addr,
667 Err(err) => {
668 return Html(
669 html! {
670 div class="alert alert-danger" {
671 "Error parsing address: " (err.to_string())
672 }
673 }
674 .into_string(),
675 );
676 }
677 };
678
679 let withdraw_payload = WithdrawPayload {
681 federation_id,
682 amount: BitcoinAmountOrAll::Amount(bitcoin::Amount::from_sat(payload.amount)),
683 address,
684 quoted_fees: Some(PegOutFees::new(
685 payload.fee_rate_sats_per_kvb,
686 payload.total_weight,
687 )),
688 };
689
690 let markup = match state.api.handle_withdraw_msg(withdraw_payload).await {
691 Ok(response) => {
692 let updated_balance = state
694 .api
695 .handle_get_balances_msg()
696 .await
697 .ok()
698 .and_then(|balances| {
699 balances
700 .ecash_balances
701 .into_iter()
702 .find(|b| b.federation_id == federation_id)
703 .map(|b| b.ecash_balance_msats)
704 })
705 .unwrap_or(Amount::ZERO);
706
707 let balance_class = if updated_balance == Amount::ZERO {
708 "alert alert-danger"
709 } else {
710 "alert alert-success"
711 };
712
713 html! {
714 div class="alert alert-success" {
716 p { strong { "Withdrawal successful!" } }
717 p { "Transaction ID: " code { (response.txid) } }
718 p { "Peg-out Fee: " (format!("{} sats", response.fees.amount().to_sat())) }
719 }
720
721 div id=(format!("balance-{}", federation_id))
723 class=(balance_class)
724 hx-swap-oob="true"
725 {
726 "Balance: " strong { (updated_balance) }
727 }
728 }
729 }
730 Err(err) => {
731 html! {
732 div class="alert alert-danger" {
733 "Error: " (err.to_string())
734 }
735 }
736 }
737 };
738 Html(markup.into_string())
739}
740
741pub async fn spend_ecash_handler<E: Display>(
742 State(state): State<UiState<DynGatewayApi<E>>>,
743 _auth: UserAuth,
744 Form(payload): Form<SpendEcashPayload>,
745) -> impl IntoResponse {
746 let federation_id = payload.federation_id;
747 let requested_amount = payload.amount;
748
749 let mut payload = payload;
751 payload.include_invite = true;
752
753 let markup = match state.api.handle_spend_ecash_msg(payload).await {
754 Ok(response) => {
755 let notes_string = response.notes.to_string();
756 let actual_amount = response.notes.total_amount();
757 let overspent = actual_amount > requested_amount;
758
759 let updated_balance = state
761 .api
762 .handle_get_balances_msg()
763 .await
764 .ok()
765 .and_then(|balances| {
766 balances
767 .ecash_balances
768 .into_iter()
769 .find(|b| b.federation_id == federation_id)
770 .map(|b| b.ecash_balance_msats)
771 })
772 .unwrap_or(Amount::ZERO);
773
774 let balance_class = if updated_balance == Amount::ZERO {
775 "alert alert-danger"
776 } else {
777 "alert alert-success"
778 };
779
780 html! {
781 div class="card card-body bg-light" {
782 div class="d-flex justify-content-between align-items-center mb-2" {
783 span class="fw-bold" { "Ecash Generated" }
784 span class="badge bg-success" { (actual_amount) }
785 }
786
787 @if overspent {
788 div class="alert alert-warning py-2 mb-2" {
789 "Note: Spent " (actual_amount) " ("
790 (actual_amount.saturating_sub(requested_amount))
791 " more than requested due to note denominations)"
792 }
793 }
794
795 div class="mb-2" {
796 label class="form-label small text-muted" { "Ecash Notes (click to copy):" }
797 textarea
798 class="form-control font-monospace"
799 rows="4"
800 readonly
801 onclick="copyToClipboard(this)"
802 style="font-size: 0.85rem;"
803 { (notes_string) }
804 small class="text-muted" { "Click to copy" }
805 }
806 }
807
808 div id=(format!("balance-{}", federation_id))
810 class=(balance_class)
811 hx-swap-oob="true"
812 {
813 "Balance: " strong { (updated_balance) }
814 }
815 }
816 }
817 Err(err) => {
818 html! {
819 div class="alert alert-danger" {
820 "Failed to generate ecash: " (err)
821 }
822 }
823 }
824 };
825 Html(markup.into_string())
826}
827
828pub async fn receive_ecash_handler<E: Display>(
829 State(state): State<UiState<DynGatewayApi<E>>>,
830 _auth: UserAuth,
831 Form(form): Form<ReceiveEcashForm>,
832) -> impl IntoResponse {
833 let notes = match form.notes.trim().parse::<OOBNotes>() {
835 Ok(n) => n,
836 Err(e) => {
837 return Html(
838 html! {
839 div class="alert alert-danger" {
840 "Invalid ecash format: " (e)
841 }
842 }
843 .into_string(),
844 );
845 }
846 };
847
848 let payload = ReceiveEcashPayload {
850 notes,
851 wait: form.wait,
852 };
853
854 let federation_id_prefix = payload.notes.federation_id_prefix();
856
857 let markup = match state.api.handle_receive_ecash_msg(payload).await {
858 Ok(response) => {
859 let (federation_id, updated_balance) = state
861 .api
862 .handle_get_balances_msg()
863 .await
864 .ok()
865 .and_then(|balances| {
866 balances
867 .ecash_balances
868 .into_iter()
869 .find(|b| b.federation_id.to_prefix() == federation_id_prefix)
870 .map(|b| (b.federation_id, b.ecash_balance_msats))
871 })
872 .expect("Federation not found");
873
874 let balance_class = if updated_balance == Amount::ZERO {
875 "alert alert-danger"
876 } else {
877 "alert alert-success"
878 };
879
880 html! {
881 div class=(balance_class) {
882 div class="d-flex justify-content-between align-items-center" {
883 span { "Ecash received successfully!" }
884 span class="badge bg-success" { (response.amount) }
885 }
886 }
887
888 div id=(format!("balance-{}", federation_id))
890 class=(balance_class)
891 hx-swap-oob="true"
892 {
893 "Balance: " strong { (updated_balance) }
894 }
895 }
896 }
897 Err(err) => {
898 html! {
899 div class="alert alert-danger" {
900 "Failed to receive ecash: " (err)
901 }
902 }
903 }
904 };
905 Html(markup.into_string())
906}