mirror of
https://github.com/aljazceru/cdk.git
synced 2025-12-19 05:35:18 +01:00
* Improve transaction confirmation UI: reorder elements, move buttons to details card, shorten button text * feat: real node status --------- Co-authored-by: thesimplekid <tsk@thesimplekid.com>
630 lines
25 KiB
Rust
630 lines
25 KiB
Rust
use std::str::FromStr;
|
|
|
|
use axum::body::Body;
|
|
use axum::extract::{Query, State};
|
|
use axum::http::StatusCode;
|
|
use axum::response::{Html, Response};
|
|
use axum::Form;
|
|
use cdk_common::util::hex;
|
|
use ldk_node::lightning::offers::offer::Offer;
|
|
use ldk_node::lightning_invoice::Bolt11Invoice;
|
|
use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus};
|
|
use maud::html;
|
|
use serde::Deserialize;
|
|
|
|
use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming};
|
|
use crate::web::handlers::AppState;
|
|
use crate::web::templates::{
|
|
error_message, form_card, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
|
|
layout_with_status, payment_list_item, success_message,
|
|
};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PaymentsQuery {
|
|
filter: Option<String>,
|
|
page: Option<u32>,
|
|
per_page: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PayBolt11Form {
|
|
invoice: String,
|
|
#[serde(deserialize_with = "deserialize_optional_u64")]
|
|
amount_btc: Option<u64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct PayBolt12Form {
|
|
offer: String,
|
|
#[serde(deserialize_with = "deserialize_optional_u64")]
|
|
amount_btc: Option<u64>,
|
|
}
|
|
|
|
pub async fn payments_page(
|
|
State(state): State<AppState>,
|
|
query: Query<PaymentsQuery>,
|
|
) -> Result<Html<String>, StatusCode> {
|
|
let filter = query.filter.as_deref().unwrap_or("all");
|
|
let page = query.page.unwrap_or(1).max(1);
|
|
let per_page = query.per_page.unwrap_or(25).clamp(10, 100); // Limit between 10-100 items per page
|
|
|
|
// Use efficient pagination function
|
|
let (current_page_payments, total_count) = get_paginated_payments_streaming(
|
|
&state.node.inner,
|
|
filter,
|
|
((page - 1) * per_page) as usize,
|
|
per_page as usize,
|
|
);
|
|
|
|
// Calculate pagination
|
|
let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as u32;
|
|
let start_index = ((page - 1) * per_page) as usize;
|
|
let end_index = (start_index + per_page as usize).min(total_count);
|
|
|
|
// Helper function to build URL with pagination params
|
|
let build_url = |new_page: u32, new_filter: &str, new_per_page: u32| -> String {
|
|
let mut params = vec![];
|
|
if new_filter != "all" {
|
|
params.push(format!("filter={}", new_filter));
|
|
}
|
|
if new_page != 1 {
|
|
params.push(format!("page={}", new_page));
|
|
}
|
|
if new_per_page != 25 {
|
|
params.push(format!("per_page={}", new_per_page));
|
|
}
|
|
|
|
if params.is_empty() {
|
|
"/payments".to_string()
|
|
} else {
|
|
format!("/payments?{}", params.join("&"))
|
|
}
|
|
};
|
|
|
|
let content = html! {
|
|
h2 style="text-align: center; margin-bottom: 3rem;" { "Payments" }
|
|
div class="card" {
|
|
div class="payment-list-header" {
|
|
div {
|
|
h2 { "Payment History" }
|
|
@if total_count > 0 {
|
|
p style="margin: 0.25rem 0 0 0; color: #666; font-size: 0.9rem;" {
|
|
"Showing " (start_index + 1) " to " (end_index) " of " (total_count) " payments"
|
|
}
|
|
}
|
|
}
|
|
div class="payment-filter-tabs" {
|
|
a href=(build_url(1, "all", per_page)) class=(if filter == "all" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "All" }
|
|
a href=(build_url(1, "incoming", per_page)) class=(if filter == "incoming" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Incoming" }
|
|
a href=(build_url(1, "outgoing", per_page)) class=(if filter == "outgoing" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Outgoing" }
|
|
}
|
|
}
|
|
|
|
// Payment list (no metrics here)
|
|
@if current_page_payments.is_empty() {
|
|
@if total_count == 0 {
|
|
p { "No payments found." }
|
|
} @else {
|
|
p { "No payments found on this page. "
|
|
a href=(build_url(1, filter, per_page)) { "Go to first page" }
|
|
}
|
|
}
|
|
} @else {
|
|
@for payment in ¤t_page_payments {
|
|
@let direction_str = match payment.direction {
|
|
PaymentDirection::Inbound => "Inbound",
|
|
PaymentDirection::Outbound => "Outbound",
|
|
};
|
|
|
|
@let status_str = match payment.status {
|
|
PaymentStatus::Pending => "Pending",
|
|
PaymentStatus::Succeeded => "Succeeded",
|
|
PaymentStatus::Failed => "Failed",
|
|
};
|
|
|
|
@let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string());
|
|
|
|
@let (payment_hash, description, payment_type, preimage) = match &payment.kind {
|
|
PaymentKind::Bolt11 { hash, preimage, .. } => {
|
|
(Some(hash.to_string()), None::<String>, "BOLT11", preimage.map(|p| p.to_string()))
|
|
},
|
|
PaymentKind::Bolt12Offer { hash, offer_id, preimage, .. } => {
|
|
// For BOLT12, we can use either the payment hash or offer ID
|
|
let identifier = hash.map(|h| h.to_string()).unwrap_or_else(|| offer_id.to_string());
|
|
(Some(identifier), None::<String>, "BOLT12", preimage.map(|p| p.to_string()))
|
|
},
|
|
PaymentKind::Bolt12Refund { hash, preimage, .. } => {
|
|
(hash.map(|h| h.to_string()), None::<String>, "BOLT12", preimage.map(|p| p.to_string()))
|
|
},
|
|
PaymentKind::Spontaneous { hash, preimage, .. } => {
|
|
(Some(hash.to_string()), None::<String>, "Spontaneous", preimage.map(|p| p.to_string()))
|
|
},
|
|
PaymentKind::Onchain { txid, .. } => {
|
|
(Some(txid.to_string()), None::<String>, "On-chain", None)
|
|
},
|
|
PaymentKind::Bolt11Jit { hash, .. } => {
|
|
(Some(hash.to_string()), None::<String>, "BOLT11 JIT", None)
|
|
},
|
|
};
|
|
|
|
(payment_list_item(
|
|
&payment.id.to_string(),
|
|
direction_str,
|
|
status_str,
|
|
&amount_str,
|
|
payment_hash.as_deref(),
|
|
description.as_deref(),
|
|
Some(payment.latest_update_timestamp), // Use the actual timestamp
|
|
payment_type,
|
|
preimage.as_deref(),
|
|
))
|
|
}
|
|
}
|
|
|
|
// Pagination controls (bottom)
|
|
@if total_pages > 1 {
|
|
div class="pagination-controls" style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee;" {
|
|
div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;" {
|
|
// Previous page
|
|
@if page > 1 {
|
|
a href=(build_url(page - 1, filter, per_page)) class="pagination-btn" { "← Previous" }
|
|
} @else {
|
|
span class="pagination-btn disabled" { "← Previous" }
|
|
}
|
|
|
|
// Page numbers
|
|
@let start_page = (page.saturating_sub(2)).max(1);
|
|
@let end_page = (page + 2).min(total_pages);
|
|
|
|
@if start_page > 1 {
|
|
a href=(build_url(1, filter, per_page)) class="pagination-number" { "1" }
|
|
@if start_page > 2 {
|
|
span class="pagination-ellipsis" { "..." }
|
|
}
|
|
}
|
|
|
|
@for p in start_page..=end_page {
|
|
@if p == page {
|
|
span class="pagination-number active" { (p) }
|
|
} @else {
|
|
a href=(build_url(p, filter, per_page)) class="pagination-number" { (p) }
|
|
}
|
|
}
|
|
|
|
@if end_page < total_pages {
|
|
@if end_page < total_pages - 1 {
|
|
span class="pagination-ellipsis" { "..." }
|
|
}
|
|
a href=(build_url(total_pages, filter, per_page)) class="pagination-number" { (total_pages) }
|
|
}
|
|
|
|
// Next page
|
|
@if page < total_pages {
|
|
a href=(build_url(page + 1, filter, per_page)) class="pagination-btn" { "Next →" }
|
|
} @else {
|
|
span class="pagination-btn disabled" { "Next →" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compact per-page selector integrated with pagination
|
|
@if total_count > 0 {
|
|
div class="per-page-selector" {
|
|
label for="per-page" { "Show:" }
|
|
select id="per-page" onchange="changePage()" {
|
|
option value="10" selected[per_page == 10] { "10" }
|
|
option value="25" selected[per_page == 25] { "25" }
|
|
option value="50" selected[per_page == 50] { "50" }
|
|
option value="100" selected[per_page == 100] { "100" }
|
|
}
|
|
span { "per page" }
|
|
}
|
|
}
|
|
}
|
|
|
|
// JavaScript for per-page selector
|
|
script {
|
|
"function changePage() {
|
|
const perPageSelect = document.getElementById('per-page');
|
|
const newPerPage = perPageSelect.value;
|
|
const currentUrl = new URL(window.location);
|
|
currentUrl.searchParams.set('per_page', newPerPage);
|
|
currentUrl.searchParams.set('page', '1'); // Reset to first page when changing per_page
|
|
window.location.href = currentUrl.toString();
|
|
}"
|
|
}
|
|
};
|
|
|
|
let is_running = is_node_running(&state.node.inner);
|
|
Ok(Html(
|
|
layout_with_status("Payment History", content, is_running).into_string(),
|
|
))
|
|
}
|
|
|
|
pub async fn send_payments_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
|
|
let content = html! {
|
|
h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" }
|
|
div class="grid" {
|
|
(form_card(
|
|
"Pay BOLT11 Invoice",
|
|
html! {
|
|
form method="post" action="/payments/bolt11" {
|
|
div class="form-group" {
|
|
label for="invoice" { "BOLT11 Invoice" }
|
|
textarea id="invoice" name="invoice" required placeholder="lnbc..." style="height: 120px;" {}
|
|
}
|
|
div class="form-group" {
|
|
label for="amount_btc" { "Amount Override (optional)" }
|
|
input type="number" id="amount_btc" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
|
|
}
|
|
button type="submit" { "Pay BOLT11 Invoice" }
|
|
}
|
|
}
|
|
))
|
|
|
|
(form_card(
|
|
"Pay BOLT12 Offer",
|
|
html! {
|
|
form method="post" action="/payments/bolt12" {
|
|
div class="form-group" {
|
|
label for="offer" { "BOLT12 Offer" }
|
|
textarea id="offer" name="offer" required placeholder="lno..." style="height: 120px;" {}
|
|
}
|
|
div class="form-group" {
|
|
label for="amount_btc" { "Amount (required for variable amount offers)" }
|
|
input type="number" id="amount_btc" name="amount_btc" placeholder="Required for variable amount offers, ignored for fixed amount offers" step="1" {}
|
|
}
|
|
button type="submit" { "Pay BOLT12 Offer" }
|
|
}
|
|
}
|
|
))
|
|
}
|
|
|
|
};
|
|
|
|
let is_running = is_node_running(&state.node.inner);
|
|
Ok(Html(
|
|
layout_with_status("Send Payments", content, is_running).into_string(),
|
|
))
|
|
}
|
|
|
|
pub async fn post_pay_bolt11(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<PayBolt11Form>,
|
|
) -> Result<Response, StatusCode> {
|
|
let invoice = match Bolt11Invoice::from_str(form.invoice.trim()) {
|
|
Ok(inv) => inv,
|
|
Err(e) => {
|
|
tracing::warn!("Web interface: Invalid BOLT11 invoice provided: {}", e);
|
|
let content = html! {
|
|
(error_message(&format!("Invalid BOLT11 invoice: {e}")))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
};
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::BAD_REQUEST)
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Error", content, true).into_string(),
|
|
))
|
|
.unwrap());
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
"Web interface: Attempting to pay BOLT11 invoice payment_hash={}, amount_override={:?}",
|
|
invoice.payment_hash(),
|
|
form.amount_btc
|
|
);
|
|
|
|
let payment_id = if let Some(amount_btc) = form.amount_btc {
|
|
// Convert Bitcoin to millisatoshis
|
|
let amount_msats = amount_btc * 1000;
|
|
state
|
|
.node
|
|
.inner
|
|
.bolt11_payment()
|
|
.send_using_amount(&invoice, amount_msats, None)
|
|
} else {
|
|
state.node.inner.bolt11_payment().send(&invoice, None)
|
|
};
|
|
|
|
let payment_id = match payment_id {
|
|
Ok(id) => {
|
|
tracing::info!(
|
|
"Web interface: BOLT11 payment initiated with payment_id={}",
|
|
hex::encode(id.0)
|
|
);
|
|
id
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Web interface: Failed to initiate BOLT11 payment: {}", e);
|
|
let content = html! {
|
|
(error_message(&format!("Failed to initiate payment: {e}")))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
};
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Error", content, true).into_string(),
|
|
))
|
|
.unwrap());
|
|
}
|
|
};
|
|
|
|
// Wait for payment to complete (max 10 seconds)
|
|
let start = std::time::Instant::now();
|
|
let timeout = std::time::Duration::from_secs(10);
|
|
|
|
let payment_result = loop {
|
|
if let Some(details) = state.node.inner.payment(&payment_id) {
|
|
match details.status {
|
|
PaymentStatus::Succeeded => {
|
|
tracing::info!(
|
|
"Web interface: BOLT11 payment succeeded for payment_hash={}",
|
|
invoice.payment_hash()
|
|
);
|
|
break Ok(details);
|
|
}
|
|
PaymentStatus::Failed => {
|
|
tracing::error!(
|
|
"Web interface: BOLT11 payment failed for payment_hash={}",
|
|
invoice.payment_hash()
|
|
);
|
|
break Err("Payment failed".to_string());
|
|
}
|
|
PaymentStatus::Pending => {
|
|
if start.elapsed() > timeout {
|
|
tracing::warn!(
|
|
"Web interface: BOLT11 payment timeout for payment_hash={}",
|
|
invoice.payment_hash()
|
|
);
|
|
break Err("Payment is still pending after timeout".to_string());
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
break Err("Payment not found".to_string());
|
|
}
|
|
};
|
|
|
|
let content = match payment_result {
|
|
Ok(details) => {
|
|
let (preimage, fee_msats) = match details.kind {
|
|
PaymentKind::Bolt11 {
|
|
hash: _,
|
|
preimage,
|
|
secret: _,
|
|
} => (
|
|
preimage.map(|p| p.to_string()).unwrap_or_default(),
|
|
details.fee_paid_msat.unwrap_or(0),
|
|
),
|
|
_ => (String::new(), 0),
|
|
};
|
|
|
|
html! {
|
|
(success_message("Payment succeeded!"))
|
|
(info_card(
|
|
"Payment Details",
|
|
vec![
|
|
("Payment Hash", invoice.payment_hash().to_string()),
|
|
("Payment Preimage", preimage),
|
|
("Fee Paid", format_msats_as_btc(fee_msats)),
|
|
("Amount", form.amount_btc.map(|_a| format_sats_as_btc(details.amount_msat.unwrap_or(1000) / 1000)).unwrap_or_default()),
|
|
]
|
|
))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Make Another Payment" } }
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
html! {
|
|
(error_message(&format!("Payment failed: {error}")))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Response::builder()
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Result", content, true).into_string(),
|
|
))
|
|
.unwrap())
|
|
}
|
|
|
|
pub async fn post_pay_bolt12(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<PayBolt12Form>,
|
|
) -> Result<Response, StatusCode> {
|
|
let offer = match Offer::from_str(form.offer.trim()) {
|
|
Ok(offer) => offer,
|
|
Err(e) => {
|
|
tracing::warn!("Web interface: Invalid BOLT12 offer provided: {:?}", e);
|
|
let content = html! {
|
|
(error_message(&format!("Invalid BOLT12 offer: {e:?}")))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
};
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::BAD_REQUEST)
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Error", content, true).into_string(),
|
|
))
|
|
.unwrap());
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
"Web interface: Attempting to pay BOLT12 offer offer_id={}, amount_override={:?}",
|
|
offer.id(),
|
|
form.amount_btc
|
|
);
|
|
|
|
// Determine payment method based on offer type and user input
|
|
let payment_id = match offer.amount() {
|
|
Some(_) => {
|
|
// Fixed amount offer - use send() method, ignore user input amount
|
|
state.node.inner.bolt12_payment().send(&offer, None, None)
|
|
}
|
|
None => {
|
|
// Variable amount offer - requires user to specify amount via send_using_amount()
|
|
let amount_btc = match form.amount_btc {
|
|
Some(amount) => amount,
|
|
None => {
|
|
tracing::warn!("Web interface: Amount required for variable amount BOLT12 offer but not provided");
|
|
let content = html! {
|
|
(error_message("Amount is required for variable amount offers. This offer does not have a fixed amount, so you must specify how much you want to pay."))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
};
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::BAD_REQUEST)
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Error", content, true).into_string(),
|
|
))
|
|
.unwrap());
|
|
}
|
|
};
|
|
let amount_msats = amount_btc * 1_000;
|
|
state
|
|
.node
|
|
.inner
|
|
.bolt12_payment()
|
|
.send_using_amount(&offer, amount_msats, None, None)
|
|
}
|
|
};
|
|
|
|
let payment_id = match payment_id {
|
|
Ok(id) => {
|
|
tracing::info!(
|
|
"Web interface: BOLT12 payment initiated with payment_id={}",
|
|
hex::encode(id.0)
|
|
);
|
|
id
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Web interface: Failed to initiate BOLT12 payment: {}", e);
|
|
let content = html! {
|
|
(error_message(&format!("Failed to initiate payment: {e}")))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
};
|
|
return Ok(Response::builder()
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Error", content, true).into_string(),
|
|
))
|
|
.unwrap());
|
|
}
|
|
};
|
|
|
|
// Wait for payment to complete (max 10 seconds)
|
|
let start = std::time::Instant::now();
|
|
let timeout = std::time::Duration::from_secs(10);
|
|
|
|
let payment_result = loop {
|
|
if let Some(details) = state.node.inner.payment(&payment_id) {
|
|
match details.status {
|
|
PaymentStatus::Succeeded => {
|
|
tracing::info!(
|
|
"Web interface: BOLT12 payment succeeded for offer_id={}",
|
|
offer.id()
|
|
);
|
|
break Ok(details);
|
|
}
|
|
PaymentStatus::Failed => {
|
|
tracing::error!(
|
|
"Web interface: BOLT12 payment failed for offer_id={}",
|
|
offer.id()
|
|
);
|
|
break Err("Payment failed".to_string());
|
|
}
|
|
PaymentStatus::Pending => {
|
|
if start.elapsed() > timeout {
|
|
tracing::warn!(
|
|
"Web interface: BOLT12 payment timeout for offer_id={}",
|
|
offer.id()
|
|
);
|
|
break Err("Payment is still pending after timeout".to_string());
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
break Err("Payment not found".to_string());
|
|
}
|
|
};
|
|
|
|
let content = match payment_result {
|
|
Ok(details) => {
|
|
let (payment_hash, preimage, fee_msats) = match details.kind {
|
|
PaymentKind::Bolt12Offer {
|
|
hash,
|
|
preimage,
|
|
secret: _,
|
|
offer_id: _,
|
|
payer_note: _,
|
|
quantity: _,
|
|
} => (
|
|
hash.map(|h| h.to_string()).unwrap_or_default(),
|
|
preimage.map(|p| p.to_string()).unwrap_or_default(),
|
|
details.fee_paid_msat.unwrap_or(0),
|
|
),
|
|
_ => (String::new(), String::new(), 0),
|
|
};
|
|
|
|
html! {
|
|
(success_message("Payment succeeded!"))
|
|
(info_card(
|
|
"Payment Details",
|
|
vec![
|
|
("Payment Hash", payment_hash),
|
|
("Payment Preimage", preimage),
|
|
("Fee Paid", format_msats_as_btc(fee_msats)),
|
|
("Amount Paid", form.amount_btc.map(format_sats_as_btc).unwrap_or_else(|| {
|
|
// If no amount was specified in the form, show the actual amount from the payment details
|
|
details.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string())
|
|
})),
|
|
]
|
|
))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Make Another Payment" } }
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
html! {
|
|
(error_message(&format!("Payment failed: {error}")))
|
|
div class="card" {
|
|
a href="/payments" { button { "← Try Again" } }
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(Response::builder()
|
|
.header("content-type", "text/html")
|
|
.body(Body::from(
|
|
layout_with_status("Payment Result", content, true).into_string(),
|
|
))
|
|
.unwrap())
|
|
}
|