onchain.rs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. use std::collections::HashMap;
  2. use std::str::FromStr;
  3. use axum::body::Body;
  4. use axum::extract::{Query, State};
  5. use axum::http::StatusCode;
  6. use axum::response::{Html, Response};
  7. use axum::Form;
  8. use ldk_node::bitcoin::Address;
  9. use maud::html;
  10. use serde::{Deserialize, Serialize};
  11. use crate::web::handlers::utils::deserialize_optional_u64;
  12. use crate::web::handlers::AppState;
  13. use crate::web::templates::{
  14. error_message, form_card, format_sats_as_btc, info_card, layout, success_message,
  15. };
  16. #[derive(Deserialize, Serialize)]
  17. pub struct SendOnchainActionForm {
  18. address: String,
  19. #[serde(deserialize_with = "deserialize_optional_u64")]
  20. amount_sat: Option<u64>,
  21. send_action: String,
  22. }
  23. #[derive(Deserialize)]
  24. pub struct ConfirmOnchainForm {
  25. address: String,
  26. amount_sat: Option<u64>,
  27. send_action: String,
  28. confirmed: Option<String>,
  29. }
  30. pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
  31. let address_result = state.node.inner.onchain_payment().new_address();
  32. let content = match address_result {
  33. Ok(address) => {
  34. html! {
  35. (success_message(&format!("New address generated: {address}")))
  36. div class="card" {
  37. h2 { "Bitcoin Address" }
  38. div class="info-item" {
  39. span class="info-label" { "Address:" }
  40. span class="info-value" style="font-family: monospace; font-size: 0.9rem;" { (address.to_string()) }
  41. }
  42. }
  43. div class="card" {
  44. a href="/onchain" { button { "← Back to On-chain" } }
  45. " "
  46. a href="/onchain/new-address" { button { "Generate Another Address" } }
  47. }
  48. }
  49. }
  50. Err(e) => {
  51. html! {
  52. (error_message(&format!("Failed to generate address: {e}")))
  53. div class="card" {
  54. a href="/onchain" { button { "← Back to On-chain" } }
  55. }
  56. }
  57. }
  58. };
  59. Ok(Html(layout("New Address", content).into_string()))
  60. }
  61. pub async fn onchain_page(
  62. State(state): State<AppState>,
  63. query: Query<HashMap<String, String>>,
  64. ) -> Result<Html<String>, StatusCode> {
  65. let balances = state.node.inner.list_balances();
  66. let action = query
  67. .get("action")
  68. .map(|s| s.as_str())
  69. .unwrap_or("overview");
  70. let mut content = html! {
  71. h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
  72. // Quick Actions section - individual cards
  73. div class="card" style="margin-bottom: 2rem;" {
  74. h2 { "Quick Actions" }
  75. div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
  76. // Receive Bitcoin Card
  77. div class="quick-action-card" {
  78. h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
  79. 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." }
  80. a href="/onchain?action=receive" style="text-decoration: none;" {
  81. button class="button-outline" { "Receive Bitcoin" }
  82. }
  83. }
  84. // Send Bitcoin Card
  85. div class="quick-action-card" {
  86. h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
  87. 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." }
  88. a href="/onchain?action=send" style="text-decoration: none;" {
  89. button class="button-outline" { "Send Bitcoin" }
  90. }
  91. }
  92. }
  93. }
  94. // On-chain Balance as metric cards
  95. div class="card" {
  96. h2 { "On-chain Balance" }
  97. div class="metrics-container" {
  98. div class="metric-card" {
  99. div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
  100. div class="metric-label" { "Total Balance" }
  101. }
  102. div class="metric-card" {
  103. div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) }
  104. div class="metric-label" { "Spendable Balance" }
  105. }
  106. }
  107. }
  108. };
  109. match action {
  110. "send" => {
  111. content = html! {
  112. h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
  113. // Quick Actions section - individual cards
  114. div class="card" style="margin-bottom: 2rem;" {
  115. h2 { "Quick Actions" }
  116. div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
  117. // Receive Bitcoin Card
  118. div class="quick-action-card" {
  119. h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
  120. 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." }
  121. a href="/onchain?action=receive" style="text-decoration: none;" {
  122. button class="button-outline" { "Receive Bitcoin" }
  123. }
  124. }
  125. // Send Bitcoin Card
  126. div class="quick-action-card" {
  127. h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
  128. 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." }
  129. a href="/onchain?action=send" style="text-decoration: none;" {
  130. button class="button-outline" { "Send Bitcoin" }
  131. }
  132. }
  133. }
  134. }
  135. // Send form above balance
  136. (form_card(
  137. "Send On-chain Payment",
  138. html! {
  139. form method="post" action="/onchain/send" {
  140. div class="form-group" {
  141. label for="address" { "Recipient Address" }
  142. input type="text" id="address" name="address" required placeholder="bc1..." {}
  143. }
  144. div class="form-group" {
  145. label for="amount_sat" { "Amount (sats)" }
  146. input type="number" id="amount_sat" name="amount_sat" placeholder="0" {}
  147. }
  148. input type="hidden" id="send_action" name="send_action" value="send" {}
  149. div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" {
  150. a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } }
  151. div style="display: flex; gap: 0.5rem;" {
  152. button type="submit" onclick="document.getElementById('send_action').value='send'" { "Send Payment" }
  153. button type="submit" onclick="document.getElementById('send_action').value='send_all'; document.getElementById('amount_sat').value=''" { "Send All" }
  154. }
  155. }
  156. }
  157. }
  158. ))
  159. // On-chain Balance as metric cards
  160. div class="card" {
  161. h2 { "On-chain Balance" }
  162. div class="metrics-container" {
  163. div class="metric-card" {
  164. div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
  165. div class="metric-label" { "Total Balance" }
  166. }
  167. div class="metric-card" {
  168. div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) }
  169. div class="metric-label" { "Spendable Balance" }
  170. }
  171. }
  172. }
  173. };
  174. }
  175. "receive" => {
  176. content = html! {
  177. h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
  178. // Quick Actions section - individual cards
  179. div class="card" style="margin-bottom: 2rem;" {
  180. h2 { "Quick Actions" }
  181. div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
  182. // Receive Bitcoin Card
  183. div class="quick-action-card" {
  184. h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
  185. 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." }
  186. a href="/onchain?action=receive" style="text-decoration: none;" {
  187. button class="button-outline" { "Receive Bitcoin" }
  188. }
  189. }
  190. // Send Bitcoin Card
  191. div class="quick-action-card" {
  192. h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
  193. 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." }
  194. a href="/onchain?action=send" style="text-decoration: none;" {
  195. button class="button-outline" { "Send Bitcoin" }
  196. }
  197. }
  198. }
  199. }
  200. // Generate address form above balance
  201. (form_card(
  202. "Generate New Address",
  203. html! {
  204. form method="post" action="/onchain/new-address" {
  205. p style="margin-bottom: 2rem;" { "Click the button below to generate a new Bitcoin address for receiving on-chain payments." }
  206. div style="display: flex; justify-content: space-between; gap: 1rem;" {
  207. a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } }
  208. button class="button-primary" type="submit" { "Generate New Address" }
  209. }
  210. }
  211. }
  212. ))
  213. // On-chain Balance as metric cards
  214. div class="card" {
  215. h2 { "On-chain Balance" }
  216. div class="metrics-container" {
  217. div class="metric-card" {
  218. div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
  219. div class="metric-label" { "Total Balance" }
  220. }
  221. div class="metric-card" {
  222. div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) }
  223. div class="metric-label" { "Spendable Balance" }
  224. }
  225. }
  226. }
  227. };
  228. }
  229. _ => {
  230. // Show overview with just the balance and quick actions at the top
  231. }
  232. }
  233. Ok(Html(layout("On-chain", content).into_string()))
  234. }
  235. pub async fn post_send_onchain(
  236. State(_state): State<AppState>,
  237. Form(form): Form<SendOnchainActionForm>,
  238. ) -> Result<Response, StatusCode> {
  239. let encoded_form =
  240. serde_urlencoded::to_string(&form).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
  241. Ok(Response::builder()
  242. .status(StatusCode::FOUND)
  243. .header("Location", format!("/onchain/confirm?{}", encoded_form))
  244. .body(Body::empty())
  245. .unwrap())
  246. }
  247. pub async fn onchain_confirm_page(
  248. State(state): State<AppState>,
  249. query: Query<ConfirmOnchainForm>,
  250. ) -> Result<Response, StatusCode> {
  251. let form = query.0;
  252. // If user confirmed, execute the transaction
  253. if form.confirmed.as_deref() == Some("true") {
  254. return execute_onchain_transaction(State(state), form).await;
  255. }
  256. // Validate address
  257. let _address = match Address::from_str(&form.address) {
  258. Ok(addr) => addr,
  259. Err(e) => {
  260. let content = html! {
  261. (error_message(&format!("Invalid address: {e}")))
  262. div class="card" {
  263. a href="/onchain?action=send" { button { "← Back" } }
  264. }
  265. };
  266. return Ok(Response::builder()
  267. .status(StatusCode::BAD_REQUEST)
  268. .header("content-type", "text/html")
  269. .body(Body::from(
  270. layout("Send On-chain Error", content).into_string(),
  271. ))
  272. .unwrap());
  273. }
  274. };
  275. let balances = state.node.inner.list_balances();
  276. let spendable_balance = balances.spendable_onchain_balance_sats;
  277. // Calculate transaction details
  278. let (amount_to_send, is_send_all) = if form.send_action == "send_all" {
  279. (spendable_balance, true)
  280. } else {
  281. let amount = form.amount_sat.unwrap_or(0);
  282. if amount > spendable_balance {
  283. let content = html! {
  284. (error_message(&format!("Insufficient funds. Requested: {}, Available: {}",
  285. format_sats_as_btc(amount), format_sats_as_btc(spendable_balance))))
  286. div class="card" {
  287. a href="/onchain?action=send" { button { "← Back" } }
  288. }
  289. };
  290. return Ok(Response::builder()
  291. .status(StatusCode::BAD_REQUEST)
  292. .header("content-type", "text/html")
  293. .body(Body::from(
  294. layout("Send On-chain Error", content).into_string(),
  295. ))
  296. .unwrap());
  297. }
  298. (amount, false)
  299. };
  300. let confirmation_url = if form.send_action == "send_all" {
  301. format!(
  302. "/onchain/confirm?address={}&send_action={}&confirmed=true",
  303. urlencoding::encode(&form.address),
  304. form.send_action
  305. )
  306. } else {
  307. format!(
  308. "/onchain/confirm?address={}&amount_sat={}&send_action={}&confirmed=true",
  309. urlencoding::encode(&form.address),
  310. form.amount_sat.unwrap_or(0),
  311. form.send_action
  312. )
  313. };
  314. let content = html! {
  315. h2 style="text-align: center; margin-bottom: 3rem;" { "Confirm On-chain Transaction" }
  316. div class="card" style="border: 2px solid hsl(var(--primary)); background-color: hsl(var(--primary) / 0.05);" {
  317. h2 { "⚠️ Transaction Confirmation" }
  318. p style="color: hsl(var(--muted-foreground)); margin-bottom: 1.5rem;" {
  319. "Please review the transaction details carefully before proceeding. This action cannot be undone."
  320. }
  321. }
  322. (info_card(
  323. "Transaction Details",
  324. vec![
  325. ("Recipient Address", form.address.clone()),
  326. ("Amount to Send", if is_send_all {
  327. format!("{} (All available funds)", format_sats_as_btc(amount_to_send))
  328. } else {
  329. format_sats_as_btc(amount_to_send)
  330. }),
  331. ("Current Spendable Balance", format_sats_as_btc(spendable_balance)),
  332. ]
  333. ))
  334. @if is_send_all {
  335. 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);" {
  336. h3 style="color: hsl(32.6 75.4% 55.1%);" { "Send All Notice" }
  337. p style="color: hsl(32.6 75.4% 55.1%);" {
  338. "This transaction will send all available funds to the recipient address. "
  339. "Network fees will be deducted from the total amount automatically."
  340. }
  341. }
  342. }
  343. div class="card" {
  344. div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" {
  345. a href="/onchain?action=send" {
  346. button type="button" class="button-secondary" { "Cancel" }
  347. }
  348. div style="display: flex; gap: 0.5rem;" {
  349. a href=(confirmation_url) {
  350. button class="button-primary" {
  351. "✓ Confirm & Send Transaction"
  352. }
  353. }
  354. }
  355. }
  356. }
  357. };
  358. Ok(Response::builder()
  359. .header("content-type", "text/html")
  360. .body(Body::from(
  361. layout("Confirm Transaction", content).into_string(),
  362. ))
  363. .unwrap())
  364. }
  365. async fn execute_onchain_transaction(
  366. State(state): State<AppState>,
  367. form: ConfirmOnchainForm,
  368. ) -> Result<Response, StatusCode> {
  369. tracing::info!(
  370. "Web interface: Executing on-chain transaction to address={}, send_action={}, amount_sat={:?}",
  371. form.address,
  372. form.send_action,
  373. form.amount_sat
  374. );
  375. let address = match Address::from_str(&form.address) {
  376. Ok(addr) => addr,
  377. Err(e) => {
  378. tracing::warn!(
  379. "Web interface: Invalid address for on-chain transaction: {}",
  380. e
  381. );
  382. let content = html! {
  383. (error_message(&format!("Invalid address: {e}")))
  384. div class="card" {
  385. a href="/onchain" { button { "← Back" } }
  386. }
  387. };
  388. return Ok(Response::builder()
  389. .status(StatusCode::BAD_REQUEST)
  390. .header("content-type", "text/html")
  391. .body(Body::from(
  392. layout("Send On-chain Error", content).into_string(),
  393. ))
  394. .unwrap());
  395. }
  396. };
  397. // Handle send all action
  398. let txid_result = if form.send_action == "send_all" {
  399. tracing::info!(
  400. "Web interface: Sending all available funds to {}",
  401. form.address
  402. );
  403. state.node.inner.onchain_payment().send_all_to_address(
  404. address.assume_checked_ref(),
  405. false,
  406. None,
  407. )
  408. } else {
  409. let amount_sats = form.amount_sat.ok_or(StatusCode::BAD_REQUEST)?;
  410. tracing::info!(
  411. "Web interface: Sending {} sats to {}",
  412. amount_sats,
  413. form.address
  414. );
  415. state.node.inner.onchain_payment().send_to_address(
  416. address.assume_checked_ref(),
  417. amount_sats,
  418. None,
  419. )
  420. };
  421. let content = match txid_result {
  422. Ok(txid) => {
  423. if form.send_action == "send_all" {
  424. tracing::info!(
  425. "Web interface: Successfully sent all available funds, txid={}",
  426. txid
  427. );
  428. } else {
  429. tracing::info!(
  430. "Web interface: Successfully sent {} sats, txid={}",
  431. form.amount_sat.unwrap_or(0),
  432. txid
  433. );
  434. }
  435. let amount = form.amount_sat;
  436. html! {
  437. (success_message("Transaction sent successfully!"))
  438. (info_card(
  439. "Transaction Details",
  440. vec![
  441. ("Transaction ID", txid.to_string()),
  442. ("Amount", if form.send_action == "send_all" {
  443. format!("{} (All available funds)", format_sats_as_btc(amount.unwrap_or(0)))
  444. } else {
  445. format_sats_as_btc(form.amount_sat.unwrap_or(0))
  446. }),
  447. ("Recipient", form.address),
  448. ]
  449. ))
  450. div class="card" {
  451. a href="/onchain" { button { "← Back to On-chain" } }
  452. }
  453. }
  454. }
  455. Err(e) => {
  456. tracing::error!("Web interface: Failed to send on-chain transaction: {}", e);
  457. html! {
  458. (error_message(&format!("Failed to send payment: {e}")))
  459. div class="card" {
  460. a href="/onchain" { button { "← Try Again" } }
  461. }
  462. }
  463. }
  464. };
  465. Ok(Response::builder()
  466. .header("content-type", "text/html")
  467. .body(Body::from(
  468. layout("Send On-chain Result", content).into_string(),
  469. ))
  470. .unwrap())
  471. }