invoices.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. use axum::body::Body;
  2. use axum::extract::State;
  3. use axum::http::StatusCode;
  4. use axum::response::{Html, Response};
  5. use axum::Form;
  6. use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description};
  7. use maud::html;
  8. use serde::Deserialize;
  9. use crate::web::handlers::utils::{deserialize_optional_f64, deserialize_optional_u32};
  10. use crate::web::handlers::AppState;
  11. use crate::web::templates::{
  12. error_message, form_card, format_sats_as_btc, info_card, layout, success_message,
  13. };
  14. #[derive(Deserialize)]
  15. pub struct CreateBolt11Form {
  16. amount_btc: u64,
  17. description: Option<String>,
  18. #[serde(deserialize_with = "deserialize_optional_u32")]
  19. expiry_seconds: Option<u32>,
  20. }
  21. #[derive(Deserialize)]
  22. pub struct CreateBolt12Form {
  23. #[serde(deserialize_with = "deserialize_optional_f64")]
  24. amount_btc: Option<f64>,
  25. description: Option<String>,
  26. #[serde(deserialize_with = "deserialize_optional_u32")]
  27. expiry_seconds: Option<u32>,
  28. }
  29. pub async fn invoices_page(State(_state): State<AppState>) -> Result<Html<String>, StatusCode> {
  30. let content = html! {
  31. h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" }
  32. div class="grid" {
  33. (form_card(
  34. "Create BOLT11 Invoice",
  35. html! {
  36. form method="post" action="/invoices/bolt11" {
  37. div class="form-group" {
  38. label for="amount_btc" { "Amount" }
  39. input type="number" id="amount_btc" name="amount_btc" required placeholder="₿0" step="0.00000001" {}
  40. }
  41. div class="form-group" {
  42. label for="description" { "Description (optional)" }
  43. input type="text" id="description" name="description" placeholder="Payment for..." {}
  44. }
  45. div class="form-group" {
  46. label for="expiry_seconds" { "Expiry (seconds, optional)" }
  47. input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {}
  48. }
  49. button type="submit" { "Create BOLT11 Invoice" }
  50. }
  51. }
  52. ))
  53. (form_card(
  54. "Create BOLT12 Offer",
  55. html! {
  56. form method="post" action="/invoices/bolt12" {
  57. div class="form-group" {
  58. label for="amount_btc" { "Amount (optional for variable amount)" }
  59. input type="number" id="amount_btc" name="amount_btc" placeholder="₿0" step="0.00000001" {}
  60. }
  61. div class="form-group" {
  62. label for="description" { "Description (optional)" }
  63. input type="text" id="description" name="description" placeholder="Payment for..." {}
  64. }
  65. div class="form-group" {
  66. label for="expiry_seconds" { "Expiry (seconds, optional)" }
  67. input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {}
  68. }
  69. button type="submit" { "Create BOLT12 Offer" }
  70. }
  71. }
  72. ))
  73. }
  74. };
  75. Ok(Html(layout("Create Invoices", content).into_string()))
  76. }
  77. pub async fn post_create_bolt11(
  78. State(state): State<AppState>,
  79. Form(form): Form<CreateBolt11Form>,
  80. ) -> Result<Response, StatusCode> {
  81. tracing::info!(
  82. "Web interface: Creating BOLT11 invoice for amount={} sats, description={:?}, expiry={}s",
  83. form.amount_btc,
  84. form.description,
  85. form.expiry_seconds.unwrap_or(3600)
  86. );
  87. // Handle optional description
  88. let description_text = form.description.clone().unwrap_or_else(|| "".to_string());
  89. let description = if description_text.is_empty() {
  90. // Use empty description for empty or missing description
  91. match Description::new("".to_string()) {
  92. Ok(desc) => Bolt11InvoiceDescription::Direct(desc),
  93. Err(_) => {
  94. // Fallback to a minimal valid description
  95. let desc = Description::new(" ".to_string()).unwrap();
  96. Bolt11InvoiceDescription::Direct(desc)
  97. }
  98. }
  99. } else {
  100. match Description::new(description_text.clone()) {
  101. Ok(desc) => Bolt11InvoiceDescription::Direct(desc),
  102. Err(e) => {
  103. tracing::warn!(
  104. "Web interface: Invalid description for BOLT11 invoice: {}",
  105. e
  106. );
  107. let content = html! {
  108. (error_message(&format!("Invalid description: {e}")))
  109. div class="card" {
  110. a href="/invoices" { button { "← Try Again" } }
  111. }
  112. };
  113. return Ok(Response::builder()
  114. .status(StatusCode::BAD_REQUEST)
  115. .header("content-type", "text/html")
  116. .body(Body::from(
  117. layout("Create Invoice Error", content).into_string(),
  118. ))
  119. .unwrap());
  120. }
  121. }
  122. };
  123. // Convert Bitcoin to millisatoshis
  124. let amount_msats = form.amount_btc * 1_000;
  125. let expiry_seconds = form.expiry_seconds.unwrap_or(3600);
  126. let invoice_result =
  127. state
  128. .node
  129. .inner
  130. .bolt11_payment()
  131. .receive(amount_msats, &description, expiry_seconds);
  132. let content = match invoice_result {
  133. Ok(invoice) => {
  134. tracing::info!(
  135. "Web interface: Successfully created BOLT11 invoice with payment_hash={}",
  136. invoice.payment_hash()
  137. );
  138. let current_time = std::time::SystemTime::now()
  139. .duration_since(std::time::UNIX_EPOCH)
  140. .unwrap_or_default()
  141. .as_secs();
  142. let description_display = if description_text.is_empty() {
  143. "None".to_string()
  144. } else {
  145. description_text.clone()
  146. };
  147. html! {
  148. (success_message("BOLT11 Invoice created successfully!"))
  149. (info_card(
  150. "Invoice Details",
  151. vec![
  152. ("Payment Hash", invoice.payment_hash().to_string()),
  153. ("Amount", format_sats_as_btc(form.amount_btc)),
  154. ("Description", description_display),
  155. ("Expires At", format!("{}", current_time + expiry_seconds as u64)),
  156. ]
  157. ))
  158. div class="card" {
  159. h3 { "Invoice (copy this to share)" }
  160. textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" {
  161. (invoice.to_string())
  162. }
  163. }
  164. div class="card" {
  165. a href="/invoices" { button { "← Create Another Invoice" } }
  166. }
  167. }
  168. }
  169. Err(e) => {
  170. tracing::error!("Web interface: Failed to create BOLT11 invoice: {}", e);
  171. html! {
  172. (error_message(&format!("Failed to create invoice: {e}")))
  173. div class="card" {
  174. a href="/invoices" { button { "← Try Again" } }
  175. }
  176. }
  177. }
  178. };
  179. Ok(Response::builder()
  180. .header("content-type", "text/html")
  181. .body(Body::from(
  182. layout("BOLT11 Invoice Created", content).into_string(),
  183. ))
  184. .unwrap())
  185. }
  186. pub async fn post_create_bolt12(
  187. State(state): State<AppState>,
  188. Form(form): Form<CreateBolt12Form>,
  189. ) -> Result<Response, StatusCode> {
  190. let expiry_seconds = form.expiry_seconds.unwrap_or(3600);
  191. let description_text = form.description.unwrap_or_else(|| "".to_string());
  192. tracing::info!(
  193. "Web interface: Creating BOLT12 offer for amount={:?} btc, description={:?}, expiry={}s",
  194. form.amount_btc,
  195. description_text,
  196. expiry_seconds
  197. );
  198. let offer_result = if let Some(amount_btc) = form.amount_btc {
  199. // Convert Bitcoin to millisatoshis (1 BTC = 100,000,000,000 msats)
  200. let amount_msats = (amount_btc * 100_000_000_000.0) as u64;
  201. state.node.inner.bolt12_payment().receive(
  202. amount_msats,
  203. &description_text,
  204. Some(expiry_seconds),
  205. None,
  206. )
  207. } else {
  208. state
  209. .node
  210. .inner
  211. .bolt12_payment()
  212. .receive_variable_amount(&description_text, Some(expiry_seconds))
  213. };
  214. let content = match offer_result {
  215. Ok(offer) => {
  216. tracing::info!(
  217. "Web interface: Successfully created BOLT12 offer with offer_id={}",
  218. offer.id()
  219. );
  220. let current_time = std::time::SystemTime::now()
  221. .duration_since(std::time::UNIX_EPOCH)
  222. .unwrap_or_default()
  223. .as_secs();
  224. let amount_display = form
  225. .amount_btc
  226. .map(|a| format_sats_as_btc((a * 100_000_000.0) as u64))
  227. .unwrap_or_else(|| "Variable amount".to_string());
  228. let description_display = if description_text.is_empty() {
  229. "None".to_string()
  230. } else {
  231. description_text
  232. };
  233. html! {
  234. (success_message("BOLT12 Offer created successfully!"))
  235. (info_card(
  236. "Offer Details",
  237. vec![
  238. ("Offer ID", offer.id().to_string()),
  239. ("Amount", amount_display),
  240. ("Description", description_display),
  241. ("Expires At", format!("{}", current_time + expiry_seconds as u64)),
  242. ]
  243. ))
  244. div class="card" {
  245. h3 { "Offer (copy this to share)" }
  246. textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" {
  247. (offer.to_string())
  248. }
  249. }
  250. div class="card" {
  251. a href="/invoices" { button { "← Create Another Offer" } }
  252. }
  253. }
  254. }
  255. Err(e) => {
  256. tracing::error!("Web interface: Failed to create BOLT12 offer: {}", e);
  257. html! {
  258. (error_message(&format!("Failed to create offer: {e}")))
  259. div class="card" {
  260. a href="/invoices" { button { "← Try Again" } }
  261. }
  262. }
  263. }
  264. };
  265. Ok(Response::builder()
  266. .header("content-type", "text/html")
  267. .body(Body::from(
  268. layout("BOLT12 Offer Created", content).into_string(),
  269. ))
  270. .unwrap())
  271. }