diff --git a/src/daemon/api/mod.rs b/src/daemon/api/mod.rs index a04832b..92995f2 100644 --- a/src/daemon/api/mod.rs +++ b/src/daemon/api/mod.rs @@ -6,6 +6,7 @@ pub mod error; pub mod info; pub mod invoices; pub mod onchain; +pub mod pay; pub mod websocket; use std::sync::Arc; @@ -30,7 +31,8 @@ use crate::daemon::types::{ ApiError, ChannelInfo, CloseChannelRequest, CreateInvoiceRequest, CreateInvoiceResponse, DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest, - ListPaymentsRequest, OutgoingPaymentResponse, SendToAddressRequest, + ListPaymentsRequest, OutgoingPaymentResponse, PayInvoiceRequest, PayInvoiceResponse, + SendToAddressRequest, }; #[derive(Clone)] @@ -50,6 +52,7 @@ pub struct AppState { (name = "channels", description = "Channel management"), (name = "payments", description = "Incoming payments"), (name = "invoices", description = "Invoice creation"), + (name = "send", description = "Outbound Lightning payments"), (name = "decode", description = "Decode Lightning artifacts"), (name = "onchain", description = "On-chain operations"), ) @@ -85,6 +88,7 @@ pub fn router(state: AppState) -> Router { .routes(routes!(create_invoice)) .routes(routes!(close_channel)) .routes(routes!(send_to_address)) + .routes(routes!(pay_invoice)) .layer(middleware::from_fn(auth::require_full_access)); let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) @@ -299,3 +303,20 @@ async fn get_outgoing_payment( ) -> Result, AppError> { invoices::handle_get_outgoing_payment(state.node, path).await } + +#[utoipa::path( + post, path = "/payinvoice", tag = "send", + request_body(content = PayInvoiceRequest, content_type = "application/x-www-form-urlencoded"), + responses( + (status = 200, body = PayInvoiceResponse), + (status = 400, body = ApiError), + (status = 500, body = ApiError), + ), + security(("basic_auth" = [])) +)] +async fn pay_invoice( + State(state): State, + Form(req): Form, +) -> Result, AppError> { + Ok(Json(pay::handle_pay_invoice(state.node, &req).await?)) +} diff --git a/src/daemon/api/pay.rs b/src/daemon/api/pay.rs new file mode 100644 index 0000000..26df85b --- /dev/null +++ b/src/daemon/api/pay.rs @@ -0,0 +1,50 @@ +use std::str::FromStr; +use std::sync::Arc; + +use hex::DisplayHex; +use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_node::Node; + +use crate::daemon::api::error::AppError; +use crate::daemon::types::{PayInvoiceRequest, PayInvoiceResponse}; + +pub async fn handle_pay_invoice( + node: Arc, + req: &PayInvoiceRequest, +) -> Result { + let invoice = Bolt11Invoice::from_str(req.invoice.trim()) + .map_err(|e| AppError::BadRequest(format!("invalid bolt11 invoice: {e}")))?; + + let bolt11 = node.bolt11_payment(); + let payment_id = match (invoice.amount_milli_satoshis(), req.amount_sat) { + (Some(_), None) => bolt11 + .send(&invoice, None) + .map_err(|e| AppError::Internal(format!("pay failed: {e}")))?, + (None, Some(amount_sat)) => bolt11 + .send_using_amount(&invoice, amount_sat * 1000, None) + .map_err(|e| AppError::Internal(format!("pay failed: {e}")))?, + (Some(invoice_msat), Some(amount_sat)) => { + if invoice_msat != amount_sat * 1000 { + return Err(AppError::BadRequest(format!( + "amountSat ({amount_sat}) does not match invoice amount ({} sat)", + invoice_msat / 1000 + ))); + } + bolt11 + .send(&invoice, None) + .map_err(|e| AppError::Internal(format!("pay failed: {e}")))? + } + (None, None) => { + return Err(AppError::BadRequest( + "zero-amount invoice requires amountSat".into(), + )) + } + }; + + let payment_hash = invoice.payment_hash().to_string(); + + Ok(PayInvoiceResponse { + payment_id: payment_id.0.to_lower_hex_string(), + payment_hash, + }) +} diff --git a/src/daemon/types.rs b/src/daemon/types.rs index 8c6202d..2ccc3f0 100644 --- a/src/daemon/types.rs +++ b/src/daemon/types.rs @@ -246,6 +246,20 @@ pub struct CloseChannelRequest { pub channel_id: String, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PayInvoiceRequest { + pub invoice: String, + pub amount_sat: Option, +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PayInvoiceResponse { + pub payment_id: String, + pub payment_hash: String, +} + #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ApiError { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d759bc0..6659c47 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -315,6 +315,27 @@ impl PayerNode { self.node.bolt11_payment().send(&invoice, None).unwrap(); } + /// Issue a fresh bolt11 invoice that other nodes can pay this PayerNode for. + pub fn create_invoice(&self, amount_sat: u64, description: &str, expiry_secs: u32) -> String { + let description = + ldk_node::lightning_invoice::Description::new(description.to_string()).unwrap(); + let description = + ldk_node::lightning_invoice::Bolt11InvoiceDescription::Direct(description); + self.node + .bolt11_payment() + .receive(amount_sat * 1000, &description, expiry_secs) + .unwrap() + .to_string() + } + + pub fn outbound_capacity_msat(&self) -> u64 { + self.node + .list_channels() + .iter() + .map(|c| c.outbound_capacity_msat) + .sum() + } + pub fn open_channel(&self, node_id: &str, addr: &str, amount_sats: u64) { let pubkey = PublicKey::from_str(node_id).unwrap(); let socket_addr = SocketAddress::from_str(addr).unwrap(); diff --git a/tests/integration.rs b/tests/integration.rs index bcd1ad6..122a4e2 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1226,3 +1226,180 @@ async fn test_decodeoffer_invalid() { let body: serde_json::Value = resp.json().await.unwrap(); assert_eq!(body["code"].as_str().unwrap(), "bad_request"); } + +#[tokio::test(flavor = "multi_thread")] +async fn test_payinvoice_invalid_bolt11() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + let resp = server + .post_form("/payinvoice", &[("invoice", "not-a-real-bolt11")]) + .await; + assert_eq!(resp.status(), 400); + + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["code"].as_str().unwrap(), "bad_request"); + assert!(body["error"] + .as_str() + .unwrap() + .to_lowercase() + .contains("bolt11")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_payinvoice_outbound_payment() { + let bitcoind = TestBitcoind::new(); + let lsp = LspNode::new(&bitcoind); + fund_lsp(&bitcoind, &lsp).await; + + let server = MdkdHandle::start(&bitcoind, None, Some(&lsp), &random_mnemonic()).await; + let payer = PayerNode::new(&bitcoind); + setup_payer_lsp_channel(&bitcoind, &payer, &lsp, 500_000).await; + + // Step 1: fund mdkd's outbound side by receiving a payment first. + // This opens the JIT mdkd<->LSP channel and leaves mdkd with the inbound funds. + let invoice: serde_json::Value = server + .post_form( + "/createinvoice", + &[ + ("amountSat", "200000"), + ("description", "fund-mdkd-for-pay-test"), + ("expirySeconds", "3600"), + ], + ) + .await + .json() + .await + .unwrap(); + let inbound_invoice = invoice["serialized"].as_str().unwrap(); + let inbound_hash = invoice["paymentHash"].as_str().unwrap().to_string(); + + payer.pay_invoice(inbound_invoice); + + let start = std::time::Instant::now(); + loop { + let resp: serde_json::Value = server + .get(&format!("/payments/incoming/{inbound_hash}")) + .await + .json() + .await + .unwrap(); + if resp["isPaid"].as_bool().unwrap_or(false) { + break; + } + if start.elapsed() > Duration::from_secs(60) { + panic!("Timed out funding mdkd via LSP JIT channel"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Step 2: PayerNode issues a fresh invoice we will pay FROM mdkd. + let payer_balance_before = payer.outbound_capacity_msat(); + let outbound_invoice = payer.create_invoice(50_000, "pay test", 3600); + + // Step 3: hit /payinvoice on mdkd and wait for it to settle. + let resp = server + .post_form("/payinvoice", &[("invoice", &outbound_invoice)]) + .await; + assert_eq!(resp.status(), 200, "/payinvoice returned non-200"); + let body: serde_json::Value = resp.json().await.unwrap(); + let payment_id = body["paymentId"].as_str().unwrap().to_string(); + assert_eq!(payment_id.len(), 64); + assert_eq!(body["paymentHash"].as_str().unwrap().len(), 64); + + let start = std::time::Instant::now(); + let settled: serde_json::Value = loop { + let resp: serde_json::Value = server + .get(&format!("/payments/outgoing/{payment_id}")) + .await + .json() + .await + .unwrap(); + if resp["isPaid"].as_bool().unwrap_or(false) { + break resp; + } + if start.elapsed() > Duration::from_secs(60) { + panic!( + "Timed out waiting for outgoing payment to settle: {:?}", + resp + ); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(1)).await; + }; + + assert!(settled["isPaid"].as_bool().unwrap()); + assert!( + settled["preimage"].as_str().is_some(), + "settled payment should expose a preimage" + ); + let sent = settled["sent"].as_u64().unwrap(); + assert_eq!(sent, 50_000, "sent should equal the invoice amount in sats"); + let fees = settled["fees"].as_u64().unwrap(); + assert!(fees < sent, "fees should be a fraction of sent amount"); + + // Verify the payer node actually received the value (defense against silent + // routing bugs where mdkd thinks the payment succeeded but the counterparty + // never saw it). + let start = std::time::Instant::now(); + loop { + payer.sync_wallets(); + let payer_balance_after = payer.outbound_capacity_msat(); + // Payer's *outbound* capacity decreases by (received - fee they took, if any) when they + // route - but since mdkd is paying THEM directly, payer's *inbound* capacity decreases. + // We assert via list_balances spendable Lightning balance increase instead. + let spendable = payer.node.list_balances().total_lightning_balance_sats; + if spendable >= 50_000 { + break; + } + if start.elapsed() > Duration::from_secs(30) { + panic!( + "Payer node never observed the inbound 50k sat (before={} after={} spendable={})", + payer_balance_before, payer_balance_after, spendable + ); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_payinvoice_amount_mismatch_400() { + let bitcoind = TestBitcoind::new(); + let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await; + + // A throwaway PayerNode just to mint a real bolt11 with an amount. + let payer = PayerNode::new(&bitcoind); + let invoice = payer.create_invoice(10_000, "mismatch test", 600); + + // amountSat that disagrees with the invoice amount must be rejected up front. + let resp = server + .post_form( + "/payinvoice", + &[("invoice", &invoice), ("amountSat", "5000")], + ) + .await; + assert_eq!(resp.status(), 400); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["code"].as_str().unwrap(), "bad_request"); + let err = body["error"].as_str().unwrap().to_lowercase(); + assert!(err.contains("does not match"), "unexpected error: {err}"); + assert!(err.contains("amountsat"), "unexpected error: {err}"); + + // Matching amountSat must pass validation. Payment itself will fail with an + // internal error (no channels in this minimal setup), but crucially it must + // not be rejected with a 400 from the validation path. + let resp = server + .post_form( + "/payinvoice", + &[("invoice", &invoice), ("amountSat", "10000")], + ) + .await; + assert_ne!( + resp.status(), + 400, + "matching amountSat should pass validation, got status {} body {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); +}