| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697 |
- 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, 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 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "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 (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)
- },
- };
- @let status_str = {
- // Helper function to determine invoice status
- fn get_invoice_status(status: PaymentStatus, direction: PaymentDirection, payment_type: &str) -> &'static str {
- match status {
- PaymentStatus::Succeeded => "Succeeded",
- PaymentStatus::Failed => "Failed",
- PaymentStatus::Pending => {
- // For inbound BOLT11 payments, show "Unpaid" instead of "Pending"
- if direction == PaymentDirection::Inbound && payment_type == "BOLT11" {
- "Unpaid"
- } else {
- "Pending"
- }
- }
- }
- }
- get_invoice_status(payment.status, payment.direction, payment_type)
- };
- @let amount_str = {
- match (payment.amount_msat, payment.fee_paid_msat) {
- (Some(amount), Some(fee)) => format_msats_as_btc(amount + fee),
- (Some(amount), None) => format_msats_as_btc(amount),
- _ => "Unknown".to_string()
- }
- };
- (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="card" {
- // Tab navigation
- div class="payment-tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); padding-bottom: 0;" {
- button type="button" class="payment-tab active" onclick="switchTab('bolt11')" data-tab="bolt11" {
- "BOLT11 Invoice"
- }
- button type="button" class="payment-tab" onclick="switchTab('bolt12')" data-tab="bolt12" {
- "BOLT12 Offer"
- }
- }
- // BOLT11 tab content
- div id="bolt11-content" class="tab-content active" {
- form method="post" action="/payments/bolt11" {
- div class="form-group" {
- label for="invoice" { "BOLT11 Invoice" }
- textarea id="invoice" name="invoice" required placeholder="lnbc..." rows="4" {}
- }
- div class="form-group" {
- label for="amount_btc_bolt11" { "Amount Override (optional)" }
- input type="number" id="amount_btc_bolt11" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
- p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
- "Only specify an amount if you want to override the invoice amount"
- }
- }
- div class="form-actions" {
- a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
- button type="submit" class="button-primary" { "Pay Invoice" }
- }
- }
- }
- // BOLT12 tab content
- div id="bolt12-content" class="tab-content" {
- form method="post" action="/payments/bolt12" {
- div class="form-group" {
- label for="offer" { "BOLT12 Offer" }
- textarea id="offer" name="offer" required placeholder="lno..." rows="4" {}
- }
- div class="form-group" {
- label for="amount_btc_bolt12" { "Amount" }
- input type="number" id="amount_btc_bolt12" name="amount_btc" placeholder="Amount in satoshis" step="1" {}
- p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
- "Required for variable amount offers, ignored for fixed amount offers"
- }
- }
- div class="form-actions" {
- a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
- button type="submit" class="button-primary" { "Pay Offer" }
- }
- }
- }
- }
- // Tab switching script
- script type="text/javascript" {
- (maud::PreEscaped(r#"
- function switchTab(tabName) {
- console.log('Switching to tab:', tabName);
- // Hide all tab contents
- const contents = document.querySelectorAll('.tab-content');
- contents.forEach(content => content.classList.remove('active'));
- // Remove active class from all tabs
- const tabs = document.querySelectorAll('.payment-tab');
- tabs.forEach(tab => tab.classList.remove('active'));
- // Show selected tab content
- const tabContent = document.getElementById(tabName + '-content');
- if (tabContent) {
- tabContent.classList.add('active');
- console.log('Activated tab content:', tabName);
- }
- // Add active class to selected tab
- const tabButton = document.querySelector('[data-tab="' + tabName + '"]');
- if (tabButton) {
- tabButton.classList.add('active');
- console.log('Activated tab button:', tabName);
- }
- }
- "#))
- }
- };
- 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())
- }
|