diff --git a/src/api/invoices.rs b/src/api/invoices.rs index a6a8934..ea57c56 100644 --- a/src/api/invoices.rs +++ b/src/api/invoices.rs @@ -9,7 +9,7 @@ use ldk_node::bitcoin::hashes::sha256; use ldk_node::bitcoin::hashes::Hash as _; use ldk_node::lightning::ln::channelmanager::PaymentId; use ldk_node::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description, Sha256}; -use ldk_node::payment::{PaymentDetails, PaymentKind, PaymentStatus}; +use ldk_node::payment::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use ldk_node::Node; use log::{error, info}; @@ -18,7 +18,8 @@ use crate::mdk::client::MdkApiClient; use crate::mdk::types::{CheckoutCustomer, CreateCheckoutRequest, RegisterInvoiceRequest}; use crate::store::invoice_metadata::{InvoiceMetadata, InvoiceMetadataStore}; use crate::types::{ - CreateInvoiceRequest, CreateInvoiceResponse, IncomingPaymentResponse, ListPaymentsRequest, + CreateInvoiceRequest, CreateInvoiceResponse, IncomingPaymentResponse, + ListOutgoingPaymentsRequest, ListPaymentsRequest, OutgoingPaymentResponse, }; /// Cap to keep BOLT11 invoices compact (smaller QR codes). @@ -259,6 +260,130 @@ pub async fn handle_list_incoming_payments( Ok(Json(payments)) } +pub async fn handle_list_outgoing_payments( + node: Arc, + metadata_store: Arc, + params: &ListOutgoingPaymentsRequest, +) -> Result>, AppError> { + let now = crate::time::seconds_since_epoch(); + let from = params.from.unwrap_or(0); + let to = params.to.unwrap_or(now); + let limit = params.limit.unwrap_or(20) as usize; + let offset = params.offset.unwrap_or(0) as usize; + let all = params.all.unwrap_or(false); + + // Start with LDK's outbound payments. + let mut payments: Vec = node + .list_payments_with_filter(|p| p.direction == PaymentDirection::Outbound) + .into_iter() + .map(|p| payment_to_outgoing(&p)) + .collect(); + + // Collect txids already known to LDK. + let known_txids: std::collections::HashSet = + payments.iter().filter_map(|p| p.tx_id.clone()).collect(); + + // Merge locally stored sends that LDK hasn't picked up yet. + if let Ok(local_sends) = metadata_store.list_outgoing_sends() { + for send in local_sends { + if !known_txids.contains(&send.txid) { + payments.push(OutgoingPaymentResponse { + payment_id: send.txid.clone(), + payment_hash: None, + preimage: None, + tx_id: Some(send.txid), + is_paid: false, + sent: Some(send.amount_sat), + fees: send.fee_sat, + invoice: None, + completed_at: None, + created_at: send.created_at, + }); + } + } + } + + // Filter by time range. + payments.retain(|p| p.created_at >= from && p.created_at <= to); + + // Filter out failed unless `all=true`. + if !all { + payments.retain(|p| p.is_paid || p.completed_at.is_none()); + } + + // Newest first. + payments.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + let page = payments.into_iter().skip(offset).take(limit).collect(); + Ok(Json(page)) +} + +pub async fn handle_get_outgoing_payment( + node: Arc, + Path(payment_id): Path, +) -> Result, AppError> { + let id_bytes = <[u8; 32]>::from_hex(&payment_id) + .map_err(|_| AppError::BadRequest("Invalid payment id hex".into()))?; + let details = node + .payment(&PaymentId(id_bytes)) + .ok_or_else(|| AppError::NotFound(format!("Payment {} not found", payment_id)))?; + if details.direction != PaymentDirection::Outbound { + return Err(AppError::NotFound(format!( + "Payment {} not found", + payment_id + ))); + } + Ok(Json(payment_to_outgoing(&details))) +} + +fn payment_to_outgoing(p: &PaymentDetails) -> OutgoingPaymentResponse { + let (payment_hash, preimage, tx_id) = match &p.kind { + PaymentKind::Onchain { txid, .. } => (None, None, Some(txid.to_string())), + PaymentKind::Bolt11 { hash, preimage, .. } + | PaymentKind::Bolt11Jit { hash, preimage, .. } => ( + Some(hash.to_string()), + preimage.map(|pi| format!("{pi}")), + None, + ), + PaymentKind::Spontaneous { hash, preimage, .. } => ( + Some(hash.to_string()), + preimage.map(|pi| format!("{pi}")), + None, + ), + PaymentKind::Bolt12Offer { hash, preimage, .. } + | PaymentKind::Bolt12Refund { hash, preimage, .. } => ( + hash.map(|h| h.to_string()), + preimage.map(|pi| format!("{pi}")), + None, + ), + }; + + let is_paid = p.status == PaymentStatus::Succeeded; + let completed_at = if p.status != PaymentStatus::Pending { + Some(p.latest_update_timestamp) + } else { + None + }; + + OutgoingPaymentResponse { + payment_id: p + .id + .0 + .iter() + .map(|b| format!("{b:02x}")) + .collect::(), + payment_hash, + preimage, + tx_id, + is_paid, + sent: p.amount_msat.map(|m| m / 1000), + fees: p.fee_paid_msat.map(|m| m / 1000), + invoice: None, + completed_at, + created_at: p.latest_update_timestamp, + } +} + /// Build an `IncomingPaymentResponse` from stored metadata + LDK payment details. fn enrich_metadata( metadata: &InvoiceMetadata, diff --git a/src/api/mod.rs b/src/api/mod.rs index 6488c54..5ba17db 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -28,8 +28,8 @@ use crate::store::invoice_metadata::InvoiceMetadataStore; use crate::types::{ ApiError, ChannelInfo, CloseChannelRequest, CreateInvoiceRequest, CreateInvoiceResponse, DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, - GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListPaymentsRequest, - SendToAddressRequest, + GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest, + ListPaymentsRequest, OutgoingPaymentResponse, SendToAddressRequest, }; #[derive(Clone)] @@ -75,6 +75,8 @@ pub fn router(state: AppState) -> Router { .routes(routes!(list_channels)) .routes(routes!(list_incoming_payments)) .routes(routes!(get_incoming_payment)) + .routes(routes!(list_outgoing_payments)) + .routes(routes!(get_outgoing_payment)) .routes(routes!(decode_invoice)) .routes(routes!(decode_offer)); @@ -262,5 +264,37 @@ async fn send_to_address( State(state): State, Form(req): Form, ) -> Result { - onchain::handle_send_to_address(state.node, &req).await + onchain::handle_send_to_address(state.node, state.metadata_store, &req).await +} + +#[utoipa::path( + get, path = "/payments/outgoing", tag = "payments", + params(ListOutgoingPaymentsRequest), + responses( + (status = 200, body = Vec), + (status = 500, body = ApiError), + ), + security(("basic_auth" = [])) +)] +async fn list_outgoing_payments( + State(state): State, + Query(params): Query, +) -> Result>, AppError> { + invoices::handle_list_outgoing_payments(state.node, state.metadata_store, ¶ms).await +} + +#[utoipa::path( + get, path = "/payments/outgoing/{payment_id}", tag = "payments", + params(("payment_id" = String, Path, description = "Hex-encoded payment ID")), + responses( + (status = 200, body = OutgoingPaymentResponse), + (status = 404, body = ApiError), + ), + security(("basic_auth" = [])) +)] +async fn get_outgoing_payment( + State(state): State, + path: Path, +) -> Result, AppError> { + invoices::handle_get_outgoing_payment(state.node, path).await } diff --git a/src/api/onchain.rs b/src/api/onchain.rs index 937d98f..0cc0b1a 100644 --- a/src/api/onchain.rs +++ b/src/api/onchain.rs @@ -2,12 +2,15 @@ use std::sync::Arc; use ldk_node::bitcoin::{Address, FeeRate}; use ldk_node::Node; +use log::error; use crate::api::error::AppError; +use crate::store::invoice_metadata::{InvoiceMetadataStore, OutgoingSendRecord}; use crate::types::SendToAddressRequest; pub async fn handle_send_to_address( node: Arc, + metadata_store: Arc, req: &SendToAddressRequest, ) -> Result { let address: Address = req @@ -30,5 +33,19 @@ pub async fn handle_send_to_address( .send_to_address(&address, req.amount_sat, fee_rate) .map_err(|e| AppError::Internal(format!("send_to_address failed: {e}")))?; - Ok(txid.to_string()) + let txid_str = txid.to_string(); + + // Store immediately so it appears in outgoing list before chain sync. + let record = OutgoingSendRecord { + txid: txid_str.clone(), + address: req.address.clone(), + amount_sat: req.amount_sat, + fee_sat: None, + created_at: crate::time::seconds_since_epoch(), + }; + if let Err(e) = metadata_store.insert_outgoing_send(&record) { + error!("Failed to store outgoing send: {e}"); + } + + Ok(txid_str) } diff --git a/src/store/invoice_metadata.rs b/src/store/invoice_metadata.rs index 9566544..07552bb 100644 --- a/src/store/invoice_metadata.rs +++ b/src/store/invoice_metadata.rs @@ -8,6 +8,15 @@ pub struct InvoiceMetadataStore { conn: Arc>, } +#[derive(Debug, Clone)] +pub struct OutgoingSendRecord { + pub txid: String, + pub address: String, + pub amount_sat: u64, + pub fee_sat: Option, + pub created_at: u64, +} + #[derive(Debug, Clone)] pub struct InvoiceMetadata { pub payment_hash: String, @@ -53,6 +62,17 @@ impl InvoiceMetadataStore { "ALTER TABLE mdk_invoice_metadata ADD COLUMN paid INTEGER NOT NULL DEFAULT 0;", ); + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS mdk_outgoing_sends ( + txid TEXT PRIMARY KEY, + address TEXT NOT NULL, + amount_sat INTEGER NOT NULL, + fee_sat INTEGER, + created_at INTEGER NOT NULL + );", + ) + .map_err(|e| io::Error::other(format!("Failed to create outgoing_sends table: {}", e)))?; + Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -136,6 +156,50 @@ impl InvoiceMetadataStore { Ok(()) } + pub fn insert_outgoing_send(&self, record: &OutgoingSendRecord) -> io::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR IGNORE INTO mdk_outgoing_sends (txid, address, amount_sat, fee_sat, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + &record.txid, + &record.address, + record.amount_sat as i64, + record.fee_sat.map(|f| f as i64), + record.created_at as i64, + ], + ) + .map_err(|e| io::Error::other(format!("Failed to insert outgoing send: {}", e)))?; + Ok(()) + } + + pub fn list_outgoing_sends(&self) -> io::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT txid, address, amount_sat, fee_sat, created_at + FROM mdk_outgoing_sends ORDER BY created_at DESC", + ) + .map_err(|e| io::Error::other(format!("Failed to prepare outgoing query: {e}")))?; + + let rows = stmt + .query_map([], |row| { + Ok(OutgoingSendRecord { + txid: row.get(0)?, + address: row.get(1)?, + amount_sat: row.get::<_, i64>(2)? as u64, + fee_sat: row.get::<_, Option>(3)?.map(|v| v as u64), + created_at: row.get::<_, i64>(4)? as u64, + }) + }) + .map_err(|e| io::Error::other(format!("Failed to query outgoing sends: {e}")))?; + + rows.map(|row| { + row.map_err(|e| io::Error::other(format!("Failed to read outgoing row: {e}"))) + }) + .collect() + } + /// List invoices with pagination. /// /// `from`/`to` always filter on `created_at`. diff --git a/src/types.rs b/src/types.rs index 64f704e..8c6202d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -117,6 +117,35 @@ pub struct IncomingPaymentResponse { pub expires_at: Option, } +#[derive(Deserialize, ToSchema, IntoParams)] +#[into_params(parameter_in = Query)] +#[serde(rename_all = "camelCase")] +pub struct ListOutgoingPaymentsRequest { + pub from: Option, + pub to: Option, + pub limit: Option, + pub offset: Option, + /// If true, include failed payments. Otherwise only successful + pending. + pub all: Option, +} + +/// Matches phoenixd's outgoing payment response shape. +/// Timestamps are unix epoch seconds. Fees are in satoshis. +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct OutgoingPaymentResponse { + pub payment_id: String, + pub payment_hash: Option, + pub preimage: Option, + pub tx_id: Option, + pub is_paid: bool, + pub sent: Option, + pub fees: Option, + pub invoice: Option, + pub completed_at: Option, + pub created_at: u64, +} + #[derive(Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct GetBalanceResponse { diff --git a/tests/integration.rs b/tests/integration.rs index 8116f10..9c3f59d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1097,6 +1097,22 @@ async fn test_sendtoaddress_success() { let txid = resp.text().await.unwrap(); assert_eq!(txid.len(), 64, "txid should be 64-char hex"); + // The send should appear immediately in outgoing payments (before chain sync). + let outgoing: Vec = + server.get("/payments/outgoing").await.json().await.unwrap(); + assert!( + !outgoing.is_empty(), + "Expected at least one outgoing payment immediately after send" + ); + let found = outgoing.iter().find(|p| p["txId"].as_str() == Some(&txid)); + assert!( + found.is_some(), + "Outgoing payment with txid {txid} not found in list: {outgoing:?}" + ); + let payment = found.unwrap(); + assert_eq!(payment["sent"].as_u64().unwrap(), send_amount); + assert!(payment["txId"].as_str().is_some()); + // Confirm the send tx, then poll until the wallet syncs. bitcoind.mine_blocks(1); let start = std::time::Instant::now(); @@ -1114,6 +1130,32 @@ async fn test_sendtoaddress_success() { } tokio::time::sleep(Duration::from_secs(1)).await; } + + // After enough confirmations (ANTI_REORG_DELAY = 6), outgoing payment should + // be marked as paid. + bitcoind.mine_blocks(6); + let start = std::time::Instant::now(); + loop { + let outgoing: Vec = + server.get("/payments/outgoing").await.json().await.unwrap(); + let payment = outgoing + .iter() + .find(|p| p["txId"].as_str() == Some(&txid)) + .expect("outgoing payment should still be in list"); + if payment["isPaid"].as_bool().unwrap() { + assert!( + payment["fees"].as_u64().is_some(), + "fees should be set after confirmation" + ); + assert!(payment["completedAt"].as_u64().is_some()); + break; + } + if start.elapsed() > Duration::from_secs(30) { + panic!("Outgoing payment never marked as paid: {payment}"); + } + bitcoind.mine_blocks(1); + tokio::time::sleep(Duration::from_secs(1)).await; + } } #[tokio::test(flavor = "multi_thread")] diff --git a/wallet.html b/wallet.html index 81d0af6..b8a4dcf 100644 --- a/wallet.html +++ b/wallet.html @@ -210,6 +210,7 @@ /* --- Truncate --- */ .truncate { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; } +.copyable:hover { color: var(--orange); text-decoration: underline; } @media (max-width: 600px) { .truncate { max-width: 100px; } } /* --- Filters --- */ @@ -366,6 +367,27 @@

Incoming Payments

+ +
+
+

Outgoing Payments

+ +
+ +
+ + + + + + +
+ +
@@ -437,6 +459,8 @@

Confirm

refreshTimer: null, payPage: 0, payPageSize: 20, + outPage: 0, + outPageSize: 20, }; // ─── DOM helpers ─── @@ -843,6 +867,54 @@

Confirm

} } +// ─── Outgoing Payments ─── +async function refreshOutgoing() { + try { + const offset = state.outPage * state.outPageSize; + const path = `/payments/outgoing?limit=${state.outPageSize}&offset=${offset}`; + const payments = await api('GET', path); + const body = $('#outgoing-body'); + body.innerHTML = ''; + + if (payments.length === 0 && state.outPage === 0) { + show($('#outgoing-empty')); + hide($('#outgoing-table')); + hide($('#outgoing-pagination')); + return; + } + + hide($('#outgoing-empty')); + show($('#outgoing-table')); + show($('#outgoing-pagination')); + + for (const p of payments) { + const badgeClass = p.isPaid ? 'badge-paid' : (p.completedAt != null ? 'badge-expired' : 'badge-pending'); + const label = p.isPaid ? 'SUCCEEDED' : (p.completedAt != null ? 'FAILED' : 'PENDING'); + const isOnchain = p.txId != null; + const typeLabel = isOnchain ? 'On-chain' : 'Lightning'; + const copyId = p.txId || p.paymentHash || p.paymentId; + const tr = document.createElement('tr'); + tr.innerHTML = ` + ${label} + ${typeLabel} + ${p.sent != null ? formatSats(p.sent) + ' sats' : '-'} + ${p.fees != null ? formatSats(p.fees) + ' sats' : '-'} + ${formatTime(p.createdAt)} + ${truncateHash(copyId)} + `; + body.appendChild(tr); + } + + const start = offset + 1; + const end = offset + payments.length; + $('#out-page-info').textContent = `${start}-${end}`; + $('#out-prev').disabled = state.outPage === 0; + $('#out-next').disabled = payments.length < state.outPageSize; + } catch (e) { + console.error('Outgoing payments refresh failed:', e); + } +} + // ─── Decode ─── async function decodeInput() { const input = $('#decode-input').value.trim(); @@ -924,6 +996,7 @@

Confirm

$('#send-amount').value = ''; $('#send-feerate').value = ''; refreshDashboard(); + refreshOutgoing(); } catch (e) { showFormError('send-error', e.message); } finally { @@ -964,7 +1037,7 @@

Confirm

}); // Lazy load data on tab switch if (name === 'channels') refreshChannels(); - if (name === 'payments') refreshPayments(); + if (name === 'payments') { refreshPayments(); refreshOutgoing(); } } // ─── Event Wiring ─── @@ -1016,6 +1089,15 @@

Confirm

$('#pay-prev').addEventListener('click', () => { if (state.payPage > 0) { state.payPage--; refreshPayments(); } }); $('#pay-next').addEventListener('click', () => { state.payPage++; refreshPayments(); }); + // Outgoing Payments - copy txid on click + $('#outgoing-body').addEventListener('click', (e) => { + const el = e.target.closest('.copyable'); + if (el) copyText(el.dataset.copy); + }); + $('#btn-refresh-outgoing').addEventListener('click', refreshOutgoing); + $('#out-prev').addEventListener('click', () => { if (state.outPage > 0) { state.outPage--; refreshOutgoing(); } }); + $('#out-next').addEventListener('click', () => { state.outPage++; refreshOutgoing(); }); + // Decode $('#btn-decode').addEventListener('click', decodeInput);