Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/daemon/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)]
Expand All @@ -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"),
)
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -299,3 +303,20 @@ async fn get_outgoing_payment(
) -> Result<Json<OutgoingPaymentResponse>, 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<AppState>,
Form(req): Form<PayInvoiceRequest>,
) -> Result<Json<PayInvoiceResponse>, AppError> {
Ok(Json(pay::handle_pay_invoice(state.node, &req).await?))
}
50 changes: 50 additions & 0 deletions src/daemon/api/pay.rs
Original file line number Diff line number Diff line change
@@ -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<Node>,
req: &PayInvoiceRequest,
) -> Result<PayInvoiceResponse, AppError> {
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should throw a bad request error if the request amount does not match the invoice amount

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left this on the wrong line. I think it is fine if both are set as long as they match. Curious what phoenixd does here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.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,
})
}
14 changes: 14 additions & 0 deletions src/daemon/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
}

#[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 {
Expand Down
21 changes: 21 additions & 0 deletions tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
177 changes: 177 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
Loading