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