Skip to content

Commit 7b47ac7

Browse files
Merge pull request #11 from moneydevkit/feat/pay-invoice-bolt11
feat(api): add /payinvoice endpoint for outbound BOLT11 payments
2 parents 3ffab50 + 1152a95 commit 7b47ac7

5 files changed

Lines changed: 284 additions & 1 deletion

File tree

src/daemon/api/mod.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod error;
66
pub mod info;
77
pub mod invoices;
88
pub mod onchain;
9+
pub mod pay;
910
pub mod websocket;
1011

1112
use std::sync::Arc;
@@ -30,7 +31,8 @@ use crate::daemon::types::{
3031
ApiError, ChannelInfo, CloseChannelRequest, CreateInvoiceRequest, CreateInvoiceResponse,
3132
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
3233
GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest,
33-
ListPaymentsRequest, OutgoingPaymentResponse, SendToAddressRequest,
34+
ListPaymentsRequest, OutgoingPaymentResponse, PayInvoiceRequest, PayInvoiceResponse,
35+
SendToAddressRequest,
3436
};
3537

3638
#[derive(Clone)]
@@ -50,6 +52,7 @@ pub struct AppState {
5052
(name = "channels", description = "Channel management"),
5153
(name = "payments", description = "Incoming payments"),
5254
(name = "invoices", description = "Invoice creation"),
55+
(name = "send", description = "Outbound Lightning payments"),
5356
(name = "decode", description = "Decode Lightning artifacts"),
5457
(name = "onchain", description = "On-chain operations"),
5558
)
@@ -85,6 +88,7 @@ pub fn router(state: AppState) -> Router {
8588
.routes(routes!(create_invoice))
8689
.routes(routes!(close_channel))
8790
.routes(routes!(send_to_address))
91+
.routes(routes!(pay_invoice))
8892
.layer(middleware::from_fn(auth::require_full_access));
8993

9094
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
@@ -299,3 +303,20 @@ async fn get_outgoing_payment(
299303
) -> Result<Json<OutgoingPaymentResponse>, AppError> {
300304
invoices::handle_get_outgoing_payment(state.node, path).await
301305
}
306+
307+
#[utoipa::path(
308+
post, path = "/payinvoice", tag = "send",
309+
request_body(content = PayInvoiceRequest, content_type = "application/x-www-form-urlencoded"),
310+
responses(
311+
(status = 200, body = PayInvoiceResponse),
312+
(status = 400, body = ApiError),
313+
(status = 500, body = ApiError),
314+
),
315+
security(("basic_auth" = []))
316+
)]
317+
async fn pay_invoice(
318+
State(state): State<AppState>,
319+
Form(req): Form<PayInvoiceRequest>,
320+
) -> Result<Json<PayInvoiceResponse>, AppError> {
321+
Ok(Json(pay::handle_pay_invoice(state.node, &req).await?))
322+
}

src/daemon/api/pay.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use std::str::FromStr;
2+
use std::sync::Arc;
3+
4+
use hex::DisplayHex;
5+
use ldk_node::lightning_invoice::Bolt11Invoice;
6+
use ldk_node::Node;
7+
8+
use crate::daemon::api::error::AppError;
9+
use crate::daemon::types::{PayInvoiceRequest, PayInvoiceResponse};
10+
11+
pub async fn handle_pay_invoice(
12+
node: Arc<Node>,
13+
req: &PayInvoiceRequest,
14+
) -> Result<PayInvoiceResponse, AppError> {
15+
let invoice = Bolt11Invoice::from_str(req.invoice.trim())
16+
.map_err(|e| AppError::BadRequest(format!("invalid bolt11 invoice: {e}")))?;
17+
18+
let bolt11 = node.bolt11_payment();
19+
let payment_id = match (invoice.amount_milli_satoshis(), req.amount_sat) {
20+
(Some(_), None) => bolt11
21+
.send(&invoice, None)
22+
.map_err(|e| AppError::Internal(format!("pay failed: {e}")))?,
23+
(None, Some(amount_sat)) => bolt11
24+
.send_using_amount(&invoice, amount_sat * 1000, None)
25+
.map_err(|e| AppError::Internal(format!("pay failed: {e}")))?,
26+
(Some(invoice_msat), Some(amount_sat)) => {
27+
if invoice_msat != amount_sat * 1000 {
28+
return Err(AppError::BadRequest(format!(
29+
"amountSat ({amount_sat}) does not match invoice amount ({} sat)",
30+
invoice_msat / 1000
31+
)));
32+
}
33+
bolt11
34+
.send(&invoice, None)
35+
.map_err(|e| AppError::Internal(format!("pay failed: {e}")))?
36+
}
37+
(None, None) => {
38+
return Err(AppError::BadRequest(
39+
"zero-amount invoice requires amountSat".into(),
40+
))
41+
}
42+
};
43+
44+
let payment_hash = invoice.payment_hash().to_string();
45+
46+
Ok(PayInvoiceResponse {
47+
payment_id: payment_id.0.to_lower_hex_string(),
48+
payment_hash,
49+
})
50+
}

src/daemon/types.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,20 @@ pub struct CloseChannelRequest {
246246
pub channel_id: String,
247247
}
248248

249+
#[derive(Deserialize, ToSchema)]
250+
#[serde(rename_all = "camelCase")]
251+
pub struct PayInvoiceRequest {
252+
pub invoice: String,
253+
pub amount_sat: Option<u64>,
254+
}
255+
256+
#[derive(Serialize, ToSchema)]
257+
#[serde(rename_all = "camelCase")]
258+
pub struct PayInvoiceResponse {
259+
pub payment_id: String,
260+
pub payment_hash: String,
261+
}
262+
249263
#[derive(Serialize, ToSchema)]
250264
#[serde(rename_all = "camelCase")]
251265
pub struct ApiError {

tests/common/mod.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,27 @@ impl PayerNode {
315315
self.node.bolt11_payment().send(&invoice, None).unwrap();
316316
}
317317

318+
/// Issue a fresh bolt11 invoice that other nodes can pay this PayerNode for.
319+
pub fn create_invoice(&self, amount_sat: u64, description: &str, expiry_secs: u32) -> String {
320+
let description =
321+
ldk_node::lightning_invoice::Description::new(description.to_string()).unwrap();
322+
let description =
323+
ldk_node::lightning_invoice::Bolt11InvoiceDescription::Direct(description);
324+
self.node
325+
.bolt11_payment()
326+
.receive(amount_sat * 1000, &description, expiry_secs)
327+
.unwrap()
328+
.to_string()
329+
}
330+
331+
pub fn outbound_capacity_msat(&self) -> u64 {
332+
self.node
333+
.list_channels()
334+
.iter()
335+
.map(|c| c.outbound_capacity_msat)
336+
.sum()
337+
}
338+
318339
pub fn open_channel(&self, node_id: &str, addr: &str, amount_sats: u64) {
319340
let pubkey = PublicKey::from_str(node_id).unwrap();
320341
let socket_addr = SocketAddress::from_str(addr).unwrap();

tests/integration.rs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,3 +1226,180 @@ async fn test_decodeoffer_invalid() {
12261226
let body: serde_json::Value = resp.json().await.unwrap();
12271227
assert_eq!(body["code"].as_str().unwrap(), "bad_request");
12281228
}
1229+
1230+
#[tokio::test(flavor = "multi_thread")]
1231+
async fn test_payinvoice_invalid_bolt11() {
1232+
let bitcoind = TestBitcoind::new();
1233+
let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await;
1234+
1235+
let resp = server
1236+
.post_form("/payinvoice", &[("invoice", "not-a-real-bolt11")])
1237+
.await;
1238+
assert_eq!(resp.status(), 400);
1239+
1240+
let body: serde_json::Value = resp.json().await.unwrap();
1241+
assert_eq!(body["code"].as_str().unwrap(), "bad_request");
1242+
assert!(body["error"]
1243+
.as_str()
1244+
.unwrap()
1245+
.to_lowercase()
1246+
.contains("bolt11"));
1247+
}
1248+
1249+
#[tokio::test(flavor = "multi_thread")]
1250+
async fn test_payinvoice_outbound_payment() {
1251+
let bitcoind = TestBitcoind::new();
1252+
let lsp = LspNode::new(&bitcoind);
1253+
fund_lsp(&bitcoind, &lsp).await;
1254+
1255+
let server = MdkdHandle::start(&bitcoind, None, Some(&lsp), &random_mnemonic()).await;
1256+
let payer = PayerNode::new(&bitcoind);
1257+
setup_payer_lsp_channel(&bitcoind, &payer, &lsp, 500_000).await;
1258+
1259+
// Step 1: fund mdkd's outbound side by receiving a payment first.
1260+
// This opens the JIT mdkd<->LSP channel and leaves mdkd with the inbound funds.
1261+
let invoice: serde_json::Value = server
1262+
.post_form(
1263+
"/createinvoice",
1264+
&[
1265+
("amountSat", "200000"),
1266+
("description", "fund-mdkd-for-pay-test"),
1267+
("expirySeconds", "3600"),
1268+
],
1269+
)
1270+
.await
1271+
.json()
1272+
.await
1273+
.unwrap();
1274+
let inbound_invoice = invoice["serialized"].as_str().unwrap();
1275+
let inbound_hash = invoice["paymentHash"].as_str().unwrap().to_string();
1276+
1277+
payer.pay_invoice(inbound_invoice);
1278+
1279+
let start = std::time::Instant::now();
1280+
loop {
1281+
let resp: serde_json::Value = server
1282+
.get(&format!("/payments/incoming/{inbound_hash}"))
1283+
.await
1284+
.json()
1285+
.await
1286+
.unwrap();
1287+
if resp["isPaid"].as_bool().unwrap_or(false) {
1288+
break;
1289+
}
1290+
if start.elapsed() > Duration::from_secs(60) {
1291+
panic!("Timed out funding mdkd via LSP JIT channel");
1292+
}
1293+
bitcoind.mine_blocks(1);
1294+
tokio::time::sleep(Duration::from_secs(2)).await;
1295+
}
1296+
1297+
// Step 2: PayerNode issues a fresh invoice we will pay FROM mdkd.
1298+
let payer_balance_before = payer.outbound_capacity_msat();
1299+
let outbound_invoice = payer.create_invoice(50_000, "pay test", 3600);
1300+
1301+
// Step 3: hit /payinvoice on mdkd and wait for it to settle.
1302+
let resp = server
1303+
.post_form("/payinvoice", &[("invoice", &outbound_invoice)])
1304+
.await;
1305+
assert_eq!(resp.status(), 200, "/payinvoice returned non-200");
1306+
let body: serde_json::Value = resp.json().await.unwrap();
1307+
let payment_id = body["paymentId"].as_str().unwrap().to_string();
1308+
assert_eq!(payment_id.len(), 64);
1309+
assert_eq!(body["paymentHash"].as_str().unwrap().len(), 64);
1310+
1311+
let start = std::time::Instant::now();
1312+
let settled: serde_json::Value = loop {
1313+
let resp: serde_json::Value = server
1314+
.get(&format!("/payments/outgoing/{payment_id}"))
1315+
.await
1316+
.json()
1317+
.await
1318+
.unwrap();
1319+
if resp["isPaid"].as_bool().unwrap_or(false) {
1320+
break resp;
1321+
}
1322+
if start.elapsed() > Duration::from_secs(60) {
1323+
panic!(
1324+
"Timed out waiting for outgoing payment to settle: {:?}",
1325+
resp
1326+
);
1327+
}
1328+
bitcoind.mine_blocks(1);
1329+
tokio::time::sleep(Duration::from_secs(1)).await;
1330+
};
1331+
1332+
assert!(settled["isPaid"].as_bool().unwrap());
1333+
assert!(
1334+
settled["preimage"].as_str().is_some(),
1335+
"settled payment should expose a preimage"
1336+
);
1337+
let sent = settled["sent"].as_u64().unwrap();
1338+
assert_eq!(sent, 50_000, "sent should equal the invoice amount in sats");
1339+
let fees = settled["fees"].as_u64().unwrap();
1340+
assert!(fees < sent, "fees should be a fraction of sent amount");
1341+
1342+
// Verify the payer node actually received the value (defense against silent
1343+
// routing bugs where mdkd thinks the payment succeeded but the counterparty
1344+
// never saw it).
1345+
let start = std::time::Instant::now();
1346+
loop {
1347+
payer.sync_wallets();
1348+
let payer_balance_after = payer.outbound_capacity_msat();
1349+
// Payer's *outbound* capacity decreases by (received - fee they took, if any) when they
1350+
// route - but since mdkd is paying THEM directly, payer's *inbound* capacity decreases.
1351+
// We assert via list_balances spendable Lightning balance increase instead.
1352+
let spendable = payer.node.list_balances().total_lightning_balance_sats;
1353+
if spendable >= 50_000 {
1354+
break;
1355+
}
1356+
if start.elapsed() > Duration::from_secs(30) {
1357+
panic!(
1358+
"Payer node never observed the inbound 50k sat (before={} after={} spendable={})",
1359+
payer_balance_before, payer_balance_after, spendable
1360+
);
1361+
}
1362+
tokio::time::sleep(Duration::from_millis(500)).await;
1363+
}
1364+
}
1365+
1366+
#[tokio::test(flavor = "multi_thread")]
1367+
async fn test_payinvoice_amount_mismatch_400() {
1368+
let bitcoind = TestBitcoind::new();
1369+
let server = MdkdHandle::start(&bitcoind, None, None, &random_mnemonic()).await;
1370+
1371+
// A throwaway PayerNode just to mint a real bolt11 with an amount.
1372+
let payer = PayerNode::new(&bitcoind);
1373+
let invoice = payer.create_invoice(10_000, "mismatch test", 600);
1374+
1375+
// amountSat that disagrees with the invoice amount must be rejected up front.
1376+
let resp = server
1377+
.post_form(
1378+
"/payinvoice",
1379+
&[("invoice", &invoice), ("amountSat", "5000")],
1380+
)
1381+
.await;
1382+
assert_eq!(resp.status(), 400);
1383+
let body: serde_json::Value = resp.json().await.unwrap();
1384+
assert_eq!(body["code"].as_str().unwrap(), "bad_request");
1385+
let err = body["error"].as_str().unwrap().to_lowercase();
1386+
assert!(err.contains("does not match"), "unexpected error: {err}");
1387+
assert!(err.contains("amountsat"), "unexpected error: {err}");
1388+
1389+
// Matching amountSat must pass validation. Payment itself will fail with an
1390+
// internal error (no channels in this minimal setup), but crucially it must
1391+
// not be rejected with a 400 from the validation path.
1392+
let resp = server
1393+
.post_form(
1394+
"/payinvoice",
1395+
&[("invoice", &invoice), ("amountSat", "10000")],
1396+
)
1397+
.await;
1398+
assert_ne!(
1399+
resp.status(),
1400+
400,
1401+
"matching amountSat should pass validation, got status {} body {}",
1402+
resp.status(),
1403+
resp.text().await.unwrap_or_default()
1404+
);
1405+
}

0 commit comments

Comments
 (0)