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
129 changes: 127 additions & 2 deletions src/api/invoices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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).
Expand Down Expand Up @@ -259,6 +260,130 @@ pub async fn handle_list_incoming_payments(
Ok(Json(payments))
}

pub async fn handle_list_outgoing_payments(
node: Arc<Node>,
metadata_store: Arc<InvoiceMetadataStore>,
params: &ListOutgoingPaymentsRequest,
) -> Result<Json<Vec<OutgoingPaymentResponse>>, 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<OutgoingPaymentResponse> = 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<String> =
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<Node>,
Path(payment_id): Path<String>,
) -> Result<Json<OutgoingPaymentResponse>, 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::<String>(),
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,
Expand Down
40 changes: 37 additions & 3 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -262,5 +264,37 @@ async fn send_to_address(
State(state): State<AppState>,
Form(req): Form<SendToAddressRequest>,
) -> Result<String, AppError> {
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<OutgoingPaymentResponse>),
(status = 500, body = ApiError),
),
security(("basic_auth" = []))
)]
async fn list_outgoing_payments(
State(state): State<AppState>,
Query(params): Query<ListOutgoingPaymentsRequest>,
) -> Result<Json<Vec<OutgoingPaymentResponse>>, AppError> {
invoices::handle_list_outgoing_payments(state.node, state.metadata_store, &params).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<AppState>,
path: Path<String>,
) -> Result<Json<OutgoingPaymentResponse>, AppError> {
invoices::handle_get_outgoing_payment(state.node, path).await
}
19 changes: 18 additions & 1 deletion src/api/onchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node>,
metadata_store: Arc<InvoiceMetadataStore>,
req: &SendToAddressRequest,
) -> Result<String, AppError> {
let address: Address = req
Expand All @@ -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)
}
64 changes: 64 additions & 0 deletions src/store/invoice_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ pub struct InvoiceMetadataStore {
conn: Arc<Mutex<Connection>>,
}

#[derive(Debug, Clone)]
pub struct OutgoingSendRecord {
pub txid: String,
pub address: String,
pub amount_sat: u64,
pub fee_sat: Option<u64>,
pub created_at: u64,
}

#[derive(Debug, Clone)]
pub struct InvoiceMetadata {
pub payment_hash: String,
Expand Down Expand Up @@ -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)),
})
Expand Down Expand Up @@ -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<Vec<OutgoingSendRecord>> {
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<i64>>(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`.
Expand Down
29 changes: 29 additions & 0 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,35 @@ pub struct IncomingPaymentResponse {
pub expires_at: Option<u64>,
}

#[derive(Deserialize, ToSchema, IntoParams)]
#[into_params(parameter_in = Query)]
#[serde(rename_all = "camelCase")]
pub struct ListOutgoingPaymentsRequest {
pub from: Option<u64>,
pub to: Option<u64>,
pub limit: Option<u64>,
pub offset: Option<u64>,
/// If true, include failed payments. Otherwise only successful + pending.
pub all: Option<bool>,
}

/// 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<String>,
pub preimage: Option<String>,
pub tx_id: Option<String>,
pub is_paid: bool,
pub sent: Option<u64>,
pub fees: Option<u64>,
pub invoice: Option<String>,
pub completed_at: Option<u64>,
pub created_at: u64,
}

#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetBalanceResponse {
Expand Down
Loading
Loading