| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- use std::collections::HashMap;
- 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 ldk_node::bitcoin::Address;
- use maud::html;
- use serde::{Deserialize, Serialize};
- use crate::web::handlers::utils::deserialize_optional_u64;
- use crate::web::handlers::AppState;
- use crate::web::templates::{
- error_message, form_card, format_sats_as_btc, info_card, layout, success_message,
- };
- #[derive(Deserialize, Serialize)]
- pub struct SendOnchainActionForm {
- address: String,
- #[serde(deserialize_with = "deserialize_optional_u64")]
- amount_sat: Option<u64>,
- send_action: String,
- }
- #[derive(Deserialize)]
- pub struct ConfirmOnchainForm {
- address: String,
- amount_sat: Option<u64>,
- send_action: String,
- confirmed: Option<String>,
- }
- pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
- let address_result = state.node.inner.onchain_payment().new_address();
- let content = match address_result {
- Ok(address) => {
- html! {
- (success_message(&format!("New address generated: {address}")))
- div class="card" {
- h2 { "Bitcoin Address" }
- div class="info-item" {
- span class="info-label" { "Address:" }
- span class="info-value" style="font-family: monospace; font-size: 0.9rem;" { (address.to_string()) }
- }
- }
- div class="card" {
- a href="/onchain" { button { "← Back to On-chain" } }
- " "
- a href="/onchain/new-address" { button { "Generate Another Address" } }
- }
- }
- }
- Err(e) => {
- html! {
- (error_message(&format!("Failed to generate address: {e}")))
- div class="card" {
- a href="/onchain" { button { "← Back to On-chain" } }
- }
- }
- }
- };
- Ok(Html(layout("New Address", content).into_string()))
- }
- pub async fn onchain_page(
- State(state): State<AppState>,
- query: Query<HashMap<String, String>>,
- ) -> Result<Html<String>, StatusCode> {
- let balances = state.node.inner.list_balances();
- let action = query
- .get("action")
- .map(|s| s.as_str())
- .unwrap_or("overview");
- let mut content = html! {
- h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
- // Quick Actions section - individual cards
- div class="card" style="margin-bottom: 2rem;" {
- h2 { "Quick Actions" }
- div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
- // Receive Bitcoin Card
- div class="quick-action-card" {
- h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
- p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
- a href="/onchain?action=receive" style="text-decoration: none;" {
- button class="button-outline" { "Receive Bitcoin" }
- }
- }
- // Send Bitcoin Card
- div class="quick-action-card" {
- h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
- p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
- a href="/onchain?action=send" style="text-decoration: none;" {
- button class="button-outline" { "Send Bitcoin" }
- }
- }
- }
- }
- // On-chain Balance as metric cards
- div class="card" {
- h2 { "On-chain Balance" }
- div class="metrics-container" {
- div class="metric-card" {
- div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
- div class="metric-label" { "Total Balance" }
- }
- div class="metric-card" {
- div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) }
- div class="metric-label" { "Spendable Balance" }
- }
- }
- }
- };
- match action {
- "send" => {
- content = html! {
- h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
- // Quick Actions section - individual cards
- div class="card" style="margin-bottom: 2rem;" {
- h2 { "Quick Actions" }
- div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
- // Receive Bitcoin Card
- div class="quick-action-card" {
- h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
- p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
- a href="/onchain?action=receive" style="text-decoration: none;" {
- button class="button-outline" { "Receive Bitcoin" }
- }
- }
- // Send Bitcoin Card
- div class="quick-action-card" {
- h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
- p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
- a href="/onchain?action=send" style="text-decoration: none;" {
- button class="button-outline" { "Send Bitcoin" }
- }
- }
- }
- }
- // Send form above balance
- (form_card(
- "Send On-chain Payment",
- html! {
- form method="post" action="/onchain/send" {
- div class="form-group" {
- label for="address" { "Recipient Address" }
- input type="text" id="address" name="address" required placeholder="bc1..." {}
- }
- div class="form-group" {
- label for="amount_sat" { "Amount (sats)" }
- input type="number" id="amount_sat" name="amount_sat" placeholder="0" {}
- }
- input type="hidden" id="send_action" name="send_action" value="send" {}
- div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" {
- a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } }
- div style="display: flex; gap: 0.5rem;" {
- button type="submit" onclick="document.getElementById('send_action').value='send'" { "Send Payment" }
- button type="submit" onclick="document.getElementById('send_action').value='send_all'; document.getElementById('amount_sat').value=''" { "Send All" }
- }
- }
- }
- }
- ))
- // On-chain Balance as metric cards
- div class="card" {
- h2 { "On-chain Balance" }
- div class="metrics-container" {
- div class="metric-card" {
- div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
- div class="metric-label" { "Total Balance" }
- }
- div class="metric-card" {
- div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) }
- div class="metric-label" { "Spendable Balance" }
- }
- }
- }
- };
- }
- "receive" => {
- content = html! {
- h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
- // Quick Actions section - individual cards
- div class="card" style="margin-bottom: 2rem;" {
- h2 { "Quick Actions" }
- div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
- // Receive Bitcoin Card
- div class="quick-action-card" {
- h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
- p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
- a href="/onchain?action=receive" style="text-decoration: none;" {
- button class="button-outline" { "Receive Bitcoin" }
- }
- }
- // Send Bitcoin Card
- div class="quick-action-card" {
- h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
- p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
- a href="/onchain?action=send" style="text-decoration: none;" {
- button class="button-outline" { "Send Bitcoin" }
- }
- }
- }
- }
- // Generate address form above balance
- (form_card(
- "Generate New Address",
- html! {
- form method="post" action="/onchain/new-address" {
- p style="margin-bottom: 2rem;" { "Click the button below to generate a new Bitcoin address for receiving on-chain payments." }
- div style="display: flex; justify-content: space-between; gap: 1rem;" {
- a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } }
- button class="button-primary" type="submit" { "Generate New Address" }
- }
- }
- }
- ))
- // On-chain Balance as metric cards
- div class="card" {
- h2 { "On-chain Balance" }
- div class="metrics-container" {
- div class="metric-card" {
- div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
- div class="metric-label" { "Total Balance" }
- }
- div class="metric-card" {
- div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) }
- div class="metric-label" { "Spendable Balance" }
- }
- }
- }
- };
- }
- _ => {
- // Show overview with just the balance and quick actions at the top
- }
- }
- Ok(Html(layout("On-chain", content).into_string()))
- }
- pub async fn post_send_onchain(
- State(_state): State<AppState>,
- Form(form): Form<SendOnchainActionForm>,
- ) -> Result<Response, StatusCode> {
- let encoded_form =
- serde_urlencoded::to_string(&form).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
- Ok(Response::builder()
- .status(StatusCode::FOUND)
- .header("Location", format!("/onchain/confirm?{}", encoded_form))
- .body(Body::empty())
- .unwrap())
- }
- pub async fn onchain_confirm_page(
- State(state): State<AppState>,
- query: Query<ConfirmOnchainForm>,
- ) -> Result<Response, StatusCode> {
- let form = query.0;
- // If user confirmed, execute the transaction
- if form.confirmed.as_deref() == Some("true") {
- return execute_onchain_transaction(State(state), form).await;
- }
- // Validate address
- let _address = match Address::from_str(&form.address) {
- Ok(addr) => addr,
- Err(e) => {
- let content = html! {
- (error_message(&format!("Invalid address: {e}")))
- div class="card" {
- a href="/onchain?action=send" { button { "← Back" } }
- }
- };
- return Ok(Response::builder()
- .status(StatusCode::BAD_REQUEST)
- .header("content-type", "text/html")
- .body(Body::from(
- layout("Send On-chain Error", content).into_string(),
- ))
- .unwrap());
- }
- };
- let balances = state.node.inner.list_balances();
- let spendable_balance = balances.spendable_onchain_balance_sats;
- // Calculate transaction details
- let (amount_to_send, is_send_all) = if form.send_action == "send_all" {
- (spendable_balance, true)
- } else {
- let amount = form.amount_sat.unwrap_or(0);
- if amount > spendable_balance {
- let content = html! {
- (error_message(&format!("Insufficient funds. Requested: {}, Available: {}",
- format_sats_as_btc(amount), format_sats_as_btc(spendable_balance))))
- div class="card" {
- a href="/onchain?action=send" { button { "← Back" } }
- }
- };
- return Ok(Response::builder()
- .status(StatusCode::BAD_REQUEST)
- .header("content-type", "text/html")
- .body(Body::from(
- layout("Send On-chain Error", content).into_string(),
- ))
- .unwrap());
- }
- (amount, false)
- };
- let confirmation_url = if form.send_action == "send_all" {
- format!(
- "/onchain/confirm?address={}&send_action={}&confirmed=true",
- urlencoding::encode(&form.address),
- form.send_action
- )
- } else {
- format!(
- "/onchain/confirm?address={}&amount_sat={}&send_action={}&confirmed=true",
- urlencoding::encode(&form.address),
- form.amount_sat.unwrap_or(0),
- form.send_action
- )
- };
- let content = html! {
- h2 style="text-align: center; margin-bottom: 3rem;" { "Confirm On-chain Transaction" }
- div class="card" style="border: 2px solid hsl(var(--primary)); background-color: hsl(var(--primary) / 0.05);" {
- h2 { "⚠️ Transaction Confirmation" }
- p style="color: hsl(var(--muted-foreground)); margin-bottom: 1.5rem;" {
- "Please review the transaction details carefully before proceeding. This action cannot be undone."
- }
- }
- (info_card(
- "Transaction Details",
- vec![
- ("Recipient Address", form.address.clone()),
- ("Amount to Send", if is_send_all {
- format!("{} (All available funds)", format_sats_as_btc(amount_to_send))
- } else {
- format_sats_as_btc(amount_to_send)
- }),
- ("Current Spendable Balance", format_sats_as_btc(spendable_balance)),
- ]
- ))
- @if is_send_all {
- div class="card" style="border: 1px solid hsl(32.6 75.4% 55.1%); background-color: hsl(32.6 75.4% 55.1% / 0.1);" {
- h3 style="color: hsl(32.6 75.4% 55.1%);" { "Send All Notice" }
- p style="color: hsl(32.6 75.4% 55.1%);" {
- "This transaction will send all available funds to the recipient address. "
- "Network fees will be deducted from the total amount automatically."
- }
- }
- }
- div class="card" {
- div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" {
- a href="/onchain?action=send" {
- button type="button" class="button-secondary" { "Cancel" }
- }
- div style="display: flex; gap: 0.5rem;" {
- a href=(confirmation_url) {
- button class="button-primary" {
- "✓ Confirm & Send Transaction"
- }
- }
- }
- }
- }
- };
- Ok(Response::builder()
- .header("content-type", "text/html")
- .body(Body::from(
- layout("Confirm Transaction", content).into_string(),
- ))
- .unwrap())
- }
- async fn execute_onchain_transaction(
- State(state): State<AppState>,
- form: ConfirmOnchainForm,
- ) -> Result<Response, StatusCode> {
- tracing::info!(
- "Web interface: Executing on-chain transaction to address={}, send_action={}, amount_sat={:?}",
- form.address,
- form.send_action,
- form.amount_sat
- );
- let address = match Address::from_str(&form.address) {
- Ok(addr) => addr,
- Err(e) => {
- tracing::warn!(
- "Web interface: Invalid address for on-chain transaction: {}",
- e
- );
- let content = html! {
- (error_message(&format!("Invalid address: {e}")))
- div class="card" {
- a href="/onchain" { button { "← Back" } }
- }
- };
- return Ok(Response::builder()
- .status(StatusCode::BAD_REQUEST)
- .header("content-type", "text/html")
- .body(Body::from(
- layout("Send On-chain Error", content).into_string(),
- ))
- .unwrap());
- }
- };
- // Handle send all action
- let txid_result = if form.send_action == "send_all" {
- tracing::info!(
- "Web interface: Sending all available funds to {}",
- form.address
- );
- state.node.inner.onchain_payment().send_all_to_address(
- address.assume_checked_ref(),
- false,
- None,
- )
- } else {
- let amount_sats = form.amount_sat.ok_or(StatusCode::BAD_REQUEST)?;
- tracing::info!(
- "Web interface: Sending {} sats to {}",
- amount_sats,
- form.address
- );
- state.node.inner.onchain_payment().send_to_address(
- address.assume_checked_ref(),
- amount_sats,
- None,
- )
- };
- let content = match txid_result {
- Ok(txid) => {
- if form.send_action == "send_all" {
- tracing::info!(
- "Web interface: Successfully sent all available funds, txid={}",
- txid
- );
- } else {
- tracing::info!(
- "Web interface: Successfully sent {} sats, txid={}",
- form.amount_sat.unwrap_or(0),
- txid
- );
- }
- let amount = form.amount_sat;
- html! {
- (success_message("Transaction sent successfully!"))
- (info_card(
- "Transaction Details",
- vec![
- ("Transaction ID", txid.to_string()),
- ("Amount", if form.send_action == "send_all" {
- format!("{} (All available funds)", format_sats_as_btc(amount.unwrap_or(0)))
- } else {
- format_sats_as_btc(form.amount_sat.unwrap_or(0))
- }),
- ("Recipient", form.address),
- ]
- ))
- div class="card" {
- a href="/onchain" { button { "← Back to On-chain" } }
- }
- }
- }
- Err(e) => {
- tracing::error!("Web interface: Failed to send on-chain transaction: {}", e);
- html! {
- (error_message(&format!("Failed to send payment: {e}")))
- div class="card" {
- a href="/onchain" { button { "← Try Again" } }
- }
- }
- }
- };
- Ok(Response::builder()
- .header("content-type", "text/html")
- .body(Body::from(
- layout("Send On-chain Result", content).into_string(),
- ))
- .unwrap())
- }
|