Skip to content

Commit 8bb561b

Browse files
Add outgoing payments API and frontend
Add REST endpoints to list and query outgoing payments (on-chain and Lightning), with immediate visibility for on-chain sends before chain sync picks them up. API: - GET /payments/outgoing - list outgoing payments with pagination - GET /payments/outgoing/{payment_id} - get single outgoing payment - Both are read-only (work with either auth tier) - Response includes kind (onchain with txid, or lightning with payment_hash), status, amount, fee, and timestamp - Auto-documented via OpenAPI at /scalar On-chain send tracking: - New mdk_outgoing_sends SQLite table stores txid, address, amount, and timestamp immediately when sendtoaddress returns - Outgoing list merges LDK payment store with local records, deduplicating by txid, so sends appear instantly as PENDING before LDK chain sync confirms them Frontend (wallet.html): - Outgoing Payments section added to Payments tab with pagination - Columns: Status, Type, Amount, Fee, Updated, ID - Txid is click-to-copy with hover highlight - Auto-refreshes after on-chain sends
1 parent e6834e8 commit 8bb561b

6 files changed

Lines changed: 345 additions & 7 deletions

File tree

src/api/invoices.rs

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ldk_node::bitcoin::hashes::sha256;
99
use ldk_node::bitcoin::hashes::Hash as _;
1010
use ldk_node::lightning::ln::channelmanager::PaymentId;
1111
use ldk_node::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description, Sha256};
12-
use ldk_node::payment::{PaymentDetails, PaymentKind, PaymentStatus};
12+
use ldk_node::payment::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus};
1313
use ldk_node::Node;
1414
use log::{error, info};
1515

@@ -18,7 +18,9 @@ use crate::mdk::client::MdkApiClient;
1818
use crate::mdk::types::{CheckoutCustomer, CreateCheckoutRequest, RegisterInvoiceRequest};
1919
use crate::store::invoice_metadata::{InvoiceMetadata, InvoiceMetadataStore};
2020
use crate::types::{
21-
CreateInvoiceRequest, CreateInvoiceResponse, IncomingPaymentResponse, ListPaymentsRequest,
21+
CreateInvoiceRequest, CreateInvoiceResponse, IncomingPaymentResponse,
22+
ListOutgoingPaymentsRequest, ListPaymentsRequest, OutgoingPaymentKind, OutgoingPaymentResponse,
23+
OutgoingPaymentStatus,
2224
};
2325

2426
/// Cap to keep BOLT11 invoices compact (smaller QR codes).
@@ -259,6 +261,111 @@ pub async fn handle_list_incoming_payments(
259261
Ok(Json(payments))
260262
}
261263

264+
pub async fn handle_list_outgoing_payments(
265+
node: Arc<Node>,
266+
metadata_store: Arc<InvoiceMetadataStore>,
267+
params: &ListOutgoingPaymentsRequest,
268+
) -> Result<Json<Vec<OutgoingPaymentResponse>>, AppError> {
269+
let limit = params.limit.unwrap_or(20) as usize;
270+
let offset = params.offset.unwrap_or(0) as usize;
271+
272+
// Start with LDK's outbound payments.
273+
let mut payments: Vec<OutgoingPaymentResponse> = node
274+
.list_payments_with_filter(|p| p.direction == PaymentDirection::Outbound)
275+
.into_iter()
276+
.map(|p| payment_to_outgoing(&p))
277+
.collect();
278+
279+
// Collect txids already known to LDK.
280+
let known_txids: std::collections::HashSet<String> = payments
281+
.iter()
282+
.filter_map(|p| match &p.kind {
283+
OutgoingPaymentKind::Onchain { txid } => Some(txid.clone()),
284+
_ => None,
285+
})
286+
.collect();
287+
288+
// Merge locally stored sends that LDK hasn't picked up yet.
289+
if let Ok(local_sends) = metadata_store.list_outgoing_sends() {
290+
for send in local_sends {
291+
if !known_txids.contains(&send.txid) {
292+
payments.push(OutgoingPaymentResponse {
293+
id: send.txid.clone(),
294+
kind: OutgoingPaymentKind::Onchain { txid: send.txid },
295+
status: OutgoingPaymentStatus::Pending,
296+
amount_sat: Some(send.amount_sat),
297+
fee_sat: send.fee_sat,
298+
updated_at: send.created_at,
299+
});
300+
}
301+
}
302+
}
303+
304+
// Newest first.
305+
payments.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
306+
307+
let page = payments.into_iter().skip(offset).take(limit).collect();
308+
Ok(Json(page))
309+
}
310+
311+
pub async fn handle_get_outgoing_payment(
312+
node: Arc<Node>,
313+
Path(payment_id): Path<String>,
314+
) -> Result<Json<OutgoingPaymentResponse>, AppError> {
315+
let id_bytes = <[u8; 32]>::from_hex(&payment_id)
316+
.map_err(|_| AppError::BadRequest("Invalid payment id hex".into()))?;
317+
let details = node
318+
.payment(&PaymentId(id_bytes))
319+
.ok_or_else(|| AppError::NotFound(format!("Payment {} not found", payment_id)))?;
320+
if details.direction != PaymentDirection::Outbound {
321+
return Err(AppError::NotFound(format!(
322+
"Payment {} not found",
323+
payment_id
324+
)));
325+
}
326+
Ok(Json(payment_to_outgoing(&details)))
327+
}
328+
329+
fn payment_to_outgoing(p: &PaymentDetails) -> OutgoingPaymentResponse {
330+
let kind = match &p.kind {
331+
PaymentKind::Onchain { txid, .. } => OutgoingPaymentKind::Onchain {
332+
txid: txid.to_string(),
333+
},
334+
other => {
335+
let hash = match other {
336+
PaymentKind::Bolt11 { hash, .. }
337+
| PaymentKind::Bolt11Jit { hash, .. }
338+
| PaymentKind::Spontaneous { hash, .. } => hash.to_string(),
339+
PaymentKind::Bolt12Offer { hash, .. } | PaymentKind::Bolt12Refund { hash, .. } => {
340+
hash.map(|h| h.to_string()).unwrap_or_default()
341+
}
342+
PaymentKind::Onchain { .. } => unreachable!(),
343+
};
344+
OutgoingPaymentKind::Lightning { payment_hash: hash }
345+
}
346+
};
347+
348+
let status = match p.status {
349+
PaymentStatus::Pending => OutgoingPaymentStatus::Pending,
350+
PaymentStatus::Succeeded => OutgoingPaymentStatus::Succeeded,
351+
PaymentStatus::Failed => OutgoingPaymentStatus::Failed,
352+
};
353+
354+
OutgoingPaymentResponse {
355+
id: p
356+
.id
357+
.0
358+
.iter()
359+
.map(|b| format!("{b:02x}"))
360+
.collect::<String>(),
361+
kind,
362+
status,
363+
amount_sat: p.amount_msat.map(|m| m / 1000),
364+
fee_sat: p.fee_paid_msat.map(|m| m / 1000),
365+
updated_at: p.latest_update_timestamp,
366+
}
367+
}
368+
262369
/// Build an `IncomingPaymentResponse` from stored metadata + LDK payment details.
263370
fn enrich_metadata(
264371
metadata: &InvoiceMetadata,

src/api/mod.rs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ use crate::store::invoice_metadata::InvoiceMetadataStore;
2828
use crate::types::{
2929
ApiError, ChannelInfo, CloseChannelRequest, CreateInvoiceRequest, CreateInvoiceResponse,
3030
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
31-
GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListPaymentsRequest,
32-
SendToAddressRequest,
31+
GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest,
32+
ListPaymentsRequest, OutgoingPaymentResponse, SendToAddressRequest,
3333
};
3434

3535
#[derive(Clone)]
@@ -75,6 +75,8 @@ pub fn router(state: AppState) -> Router {
7575
.routes(routes!(list_channels))
7676
.routes(routes!(list_incoming_payments))
7777
.routes(routes!(get_incoming_payment))
78+
.routes(routes!(list_outgoing_payments))
79+
.routes(routes!(get_outgoing_payment))
7880
.routes(routes!(decode_invoice))
7981
.routes(routes!(decode_offer));
8082

@@ -262,5 +264,37 @@ async fn send_to_address(
262264
State(state): State<AppState>,
263265
Form(req): Form<SendToAddressRequest>,
264266
) -> Result<String, AppError> {
265-
onchain::handle_send_to_address(state.node, &req).await
267+
onchain::handle_send_to_address(state.node, state.metadata_store, &req).await
268+
}
269+
270+
#[utoipa::path(
271+
get, path = "/payments/outgoing", tag = "payments",
272+
params(ListOutgoingPaymentsRequest),
273+
responses(
274+
(status = 200, body = Vec<OutgoingPaymentResponse>),
275+
(status = 500, body = ApiError),
276+
),
277+
security(("basic_auth" = []))
278+
)]
279+
async fn list_outgoing_payments(
280+
State(state): State<AppState>,
281+
Query(params): Query<ListOutgoingPaymentsRequest>,
282+
) -> Result<Json<Vec<OutgoingPaymentResponse>>, AppError> {
283+
invoices::handle_list_outgoing_payments(state.node, state.metadata_store, &params).await
284+
}
285+
286+
#[utoipa::path(
287+
get, path = "/payments/outgoing/{payment_id}", tag = "payments",
288+
params(("payment_id" = String, Path, description = "Hex-encoded payment ID")),
289+
responses(
290+
(status = 200, body = OutgoingPaymentResponse),
291+
(status = 404, body = ApiError),
292+
),
293+
security(("basic_auth" = []))
294+
)]
295+
async fn get_outgoing_payment(
296+
State(state): State<AppState>,
297+
path: Path<String>,
298+
) -> Result<Json<OutgoingPaymentResponse>, AppError> {
299+
invoices::handle_get_outgoing_payment(state.node, path).await
266300
}

src/api/onchain.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ use std::sync::Arc;
22

33
use ldk_node::bitcoin::{Address, FeeRate};
44
use ldk_node::Node;
5+
use log::error;
56

67
use crate::api::error::AppError;
8+
use crate::store::invoice_metadata::{InvoiceMetadataStore, OutgoingSendRecord};
79
use crate::types::SendToAddressRequest;
810

911
pub async fn handle_send_to_address(
1012
node: Arc<Node>,
13+
metadata_store: Arc<InvoiceMetadataStore>,
1114
req: &SendToAddressRequest,
1215
) -> Result<String, AppError> {
1316
let address: Address = req
@@ -30,5 +33,19 @@ pub async fn handle_send_to_address(
3033
.send_to_address(&address, req.amount_sat, fee_rate)
3134
.map_err(|e| AppError::Internal(format!("send_to_address failed: {e}")))?;
3235

33-
Ok(txid.to_string())
36+
let txid_str = txid.to_string();
37+
38+
// Store immediately so it appears in outgoing list before chain sync.
39+
let record = OutgoingSendRecord {
40+
txid: txid_str.clone(),
41+
address: req.address.clone(),
42+
amount_sat: req.amount_sat,
43+
fee_sat: None,
44+
created_at: crate::time::seconds_since_epoch(),
45+
};
46+
if let Err(e) = metadata_store.insert_outgoing_send(&record) {
47+
error!("Failed to store outgoing send: {e}");
48+
}
49+
50+
Ok(txid_str)
3451
}

src/store/invoice_metadata.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ pub struct InvoiceMetadataStore {
88
conn: Arc<Mutex<Connection>>,
99
}
1010

11+
#[derive(Debug, Clone)]
12+
pub struct OutgoingSendRecord {
13+
pub txid: String,
14+
pub address: String,
15+
pub amount_sat: u64,
16+
pub fee_sat: Option<u64>,
17+
pub created_at: u64,
18+
}
19+
1120
#[derive(Debug, Clone)]
1221
pub struct InvoiceMetadata {
1322
pub payment_hash: String,
@@ -53,6 +62,17 @@ impl InvoiceMetadataStore {
5362
"ALTER TABLE mdk_invoice_metadata ADD COLUMN paid INTEGER NOT NULL DEFAULT 0;",
5463
);
5564

65+
conn.execute_batch(
66+
"CREATE TABLE IF NOT EXISTS mdk_outgoing_sends (
67+
txid TEXT PRIMARY KEY,
68+
address TEXT NOT NULL,
69+
amount_sat INTEGER NOT NULL,
70+
fee_sat INTEGER,
71+
created_at INTEGER NOT NULL
72+
);",
73+
)
74+
.map_err(|e| io::Error::other(format!("Failed to create outgoing_sends table: {}", e)))?;
75+
5676
Ok(Self {
5777
conn: Arc::new(Mutex::new(conn)),
5878
})
@@ -136,6 +156,50 @@ impl InvoiceMetadataStore {
136156
Ok(())
137157
}
138158

159+
pub fn insert_outgoing_send(&self, record: &OutgoingSendRecord) -> io::Result<()> {
160+
let conn = self.conn.lock().unwrap();
161+
conn.execute(
162+
"INSERT OR IGNORE INTO mdk_outgoing_sends (txid, address, amount_sat, fee_sat, created_at)
163+
VALUES (?1, ?2, ?3, ?4, ?5)",
164+
rusqlite::params![
165+
&record.txid,
166+
&record.address,
167+
record.amount_sat as i64,
168+
record.fee_sat.map(|f| f as i64),
169+
record.created_at as i64,
170+
],
171+
)
172+
.map_err(|e| io::Error::other(format!("Failed to insert outgoing send: {}", e)))?;
173+
Ok(())
174+
}
175+
176+
pub fn list_outgoing_sends(&self) -> io::Result<Vec<OutgoingSendRecord>> {
177+
let conn = self.conn.lock().unwrap();
178+
let mut stmt = conn
179+
.prepare(
180+
"SELECT txid, address, amount_sat, fee_sat, created_at
181+
FROM mdk_outgoing_sends ORDER BY created_at DESC",
182+
)
183+
.map_err(|e| io::Error::other(format!("Failed to prepare outgoing query: {e}")))?;
184+
185+
let rows = stmt
186+
.query_map([], |row| {
187+
Ok(OutgoingSendRecord {
188+
txid: row.get(0)?,
189+
address: row.get(1)?,
190+
amount_sat: row.get::<_, i64>(2)? as u64,
191+
fee_sat: row.get::<_, Option<i64>>(3)?.map(|v| v as u64),
192+
created_at: row.get::<_, i64>(4)? as u64,
193+
})
194+
})
195+
.map_err(|e| io::Error::other(format!("Failed to query outgoing sends: {e}")))?;
196+
197+
rows.map(|row| {
198+
row.map_err(|e| io::Error::other(format!("Failed to read outgoing row: {e}")))
199+
})
200+
.collect()
201+
}
202+
139203
/// List invoices with pagination.
140204
///
141205
/// `from`/`to` always filter on `created_at`.

src/types.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,40 @@ pub struct IncomingPaymentResponse {
117117
pub expires_at: Option<u64>,
118118
}
119119

120+
#[derive(Deserialize, ToSchema, IntoParams)]
121+
#[into_params(parameter_in = Query)]
122+
#[serde(rename_all = "camelCase")]
123+
pub struct ListOutgoingPaymentsRequest {
124+
pub limit: Option<u64>,
125+
pub offset: Option<u64>,
126+
}
127+
128+
#[derive(Serialize, ToSchema)]
129+
#[serde(rename_all = "camelCase")]
130+
pub struct OutgoingPaymentResponse {
131+
pub id: String,
132+
pub kind: OutgoingPaymentKind,
133+
pub status: OutgoingPaymentStatus,
134+
pub amount_sat: Option<u64>,
135+
pub fee_sat: Option<u64>,
136+
pub updated_at: u64,
137+
}
138+
139+
#[derive(Serialize, ToSchema)]
140+
#[serde(rename_all = "snake_case")]
141+
pub enum OutgoingPaymentKind {
142+
Onchain { txid: String },
143+
Lightning { payment_hash: String },
144+
}
145+
146+
#[derive(Serialize, ToSchema)]
147+
#[serde(rename_all = "snake_case")]
148+
pub enum OutgoingPaymentStatus {
149+
Pending,
150+
Succeeded,
151+
Failed,
152+
}
153+
120154
#[derive(Serialize, ToSchema)]
121155
#[serde(rename_all = "camelCase")]
122156
pub struct GetBalanceResponse {

0 commit comments

Comments
 (0)