diff --git a/Cargo.lock b/Cargo.lock index 47b8126..c777733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,6 +1717,7 @@ dependencies = [ "tempfile", "tokio", "tokio-tungstenite 0.26.2", + "tokio-util", "toml", "tower", "utoipa", @@ -2698,6 +2699,19 @@ dependencies = [ "tungstenite 0.28.0", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/Cargo.toml b/Cargo.toml index 7814f92..daac812 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,14 @@ name = "mdkd" version = "0.0.1" edition = "2021" +[lib] +name = "mdk" +path = "src/lib.rs" + +[[bin]] +name = "mdkd" +path = "src/main.rs" + [features] demo = [] @@ -15,6 +23,7 @@ utoipa = { version = "5.4", features = ["axum_extras"] } utoipa-axum = "0.2" utoipa-scalar = { version = "0.3", features = ["axum"] } tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "rustls-tls", "socks"], default-features = false } diff --git a/src/api/auth.rs b/src/daemon/api/auth.rs similarity index 99% rename from src/api/auth.rs rename to src/daemon/api/auth.rs index 6a83cf2..c51fa02 100644 --- a/src/api/auth.rs +++ b/src/daemon/api/auth.rs @@ -7,7 +7,7 @@ use axum::Json; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; -use crate::types::ApiError; +use crate::daemon::types::ApiError; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccessLevel { diff --git a/src/api/balance.rs b/src/daemon/api/balance.rs similarity index 93% rename from src/api/balance.rs rename to src/daemon/api/balance.rs index 4fa2a61..3349460 100644 --- a/src/api/balance.rs +++ b/src/daemon/api/balance.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use axum::Json; use ldk_node::Node; -use crate::api::error::AppError; -use crate::types::GetBalanceResponse; +use crate::daemon::api::error::AppError; +use crate::daemon::types::GetBalanceResponse; /// Returns the node's Lightning and on-chain balances. /// diff --git a/src/api/channels.rs b/src/daemon/api/channels.rs similarity index 91% rename from src/api/channels.rs rename to src/daemon/api/channels.rs index 7dc6741..9fda079 100644 --- a/src/api/channels.rs +++ b/src/daemon/api/channels.rs @@ -6,8 +6,8 @@ use hex::FromHex; use ldk_node::lightning::ln::types::ChannelId; use ldk_node::Node; -use crate::api::error::AppError; -use crate::types::{ChannelInfo, CloseChannelRequest}; +use crate::daemon::api::error::AppError; +use crate::daemon::types::{ChannelInfo, CloseChannelRequest}; pub async fn handle_list_channels(node: Arc) -> Result>, AppError> { let channels = node.list_channels().iter().map(ChannelInfo::from).collect(); diff --git a/src/api/decode.rs b/src/daemon/api/decode.rs similarity index 98% rename from src/api/decode.rs rename to src/daemon/api/decode.rs index 28c57a3..dc7520a 100644 --- a/src/api/decode.rs +++ b/src/daemon/api/decode.rs @@ -4,8 +4,8 @@ use axum::Json; use ldk_node::lightning::offers::offer::{Amount, Offer}; use ldk_node::lightning_invoice::Bolt11Invoice; -use crate::api::error::AppError; -use crate::types::{ +use crate::daemon::api::error::AppError; +use crate::daemon::types::{ DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, RoutingHint, RoutingHintHop, }; @@ -100,7 +100,7 @@ mod tests { use ldk_node::bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use ldk_node::lightning::offers::offer::OfferBuilder; - use crate::types::{DecodeInvoiceRequest, DecodeOfferRequest}; + use crate::daemon::types::{DecodeInvoiceRequest, DecodeOfferRequest}; // Signet invoice with LSPS4 JIT route hint (single hop), "1 cup coffee". // { diff --git a/src/api/error.rs b/src/daemon/api/error.rs similarity index 61% rename from src/api/error.rs rename to src/daemon/api/error.rs index 3338f4c..905c06e 100644 --- a/src/api/error.rs +++ b/src/daemon/api/error.rs @@ -2,7 +2,9 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::Json; -use crate::types::ApiError; +use mdk::error::MdkError; + +use crate::daemon::types::ApiError; #[derive(Debug)] pub enum AppError { @@ -11,6 +13,18 @@ pub enum AppError { Internal(String), } +impl From for AppError { + fn from(e: MdkError) -> Self { + match e { + MdkError::InvalidInput(msg) => AppError::BadRequest(msg), + MdkError::NotFound(msg) => AppError::NotFound(msg), + MdkError::Node(msg) => AppError::Internal(msg), + MdkError::Platform { message, .. } => AppError::Internal(message), + MdkError::Network(msg) => AppError::Internal(msg), + } + } +} + impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, code, message) = match self { diff --git a/src/api/info.rs b/src/daemon/api/info.rs similarity index 88% rename from src/api/info.rs rename to src/daemon/api/info.rs index 30e297d..c9d33ea 100644 --- a/src/api/info.rs +++ b/src/daemon/api/info.rs @@ -4,8 +4,8 @@ use axum::Json; use ldk_node::bitcoin::Network; use ldk_node::Node; -use crate::api::error::AppError; -use crate::types::{ChannelInfo, GetInfoResponse}; +use crate::daemon::api::error::AppError; +use crate::daemon::types::{ChannelInfo, GetInfoResponse}; const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/api/invoices.rs b/src/daemon/api/invoices.rs similarity index 76% rename from src/api/invoices.rs rename to src/daemon/api/invoices.rs index ea57c56..d5b84c6 100644 --- a/src/api/invoices.rs +++ b/src/daemon/api/invoices.rs @@ -3,21 +3,18 @@ use std::sync::Arc; use axum::extract::Path; use axum::Json; -use chrono::{DateTime, SecondsFormat}; use hex::FromHex; -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, PaymentDirection, PaymentKind, PaymentStatus}; use ldk_node::Node; -use log::{error, info}; +use log::error; -use crate::api::error::AppError; -use crate::mdk::client::MdkApiClient; -use crate::mdk::types::{CheckoutCustomer, CreateCheckoutRequest, RegisterInvoiceRequest}; -use crate::store::invoice_metadata::{InvoiceMetadata, InvoiceMetadataStore}; -use crate::types::{ +use mdk::client::MdkClient; +use mdk::types::{CreateCheckoutParams, Customer, InvoiceDescription}; + +use crate::daemon::api::error::AppError; +use crate::daemon::store::invoice_metadata::{InvoiceMetadata, InvoiceMetadataStore}; +use crate::daemon::types::{ CreateInvoiceRequest, CreateInvoiceResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest, ListPaymentsRequest, OutgoingPaymentResponse, }; @@ -26,9 +23,7 @@ use crate::types::{ /// Not a spec limit. Use `descriptionHash` for longer descriptions. const MAX_DESCRIPTION_LEN: usize = 128; -const DEFAULT_EXPIRY_SECS: u32 = 3600; - -fn parse_description(req: &CreateInvoiceRequest) -> Result { +fn parse_description(req: &CreateInvoiceRequest) -> Result { match (&req.description, &req.description_hash) { (Some(desc), None) => { if desc.len() > MAX_DESCRIPTION_LEN { @@ -36,16 +31,12 @@ fn parse_description(req: &CreateInvoiceRequest) -> Result { let bytes = <[u8; 32]>::from_hex(hash_hex) .map_err(|e| AppError::BadRequest(format!("Invalid descriptionHash: {e}")))?; - Ok(Bolt11InvoiceDescription::Hash(Sha256( - sha256::Hash::from_byte_array(bytes), - ))) + Ok(InvoiceDescription::Hash(bytes)) } _ => Err(AppError::BadRequest( "Must provide either description or descriptionHash".into(), @@ -54,56 +45,13 @@ fn parse_description(req: &CreateInvoiceRequest) -> Result, + mdk_client: Arc, metadata_store: Arc, - mdk_client: Arc, req: &CreateInvoiceRequest, ) -> Result, AppError> { let description = parse_description(req)?; - let expiry_secs = req.expiry_seconds.unwrap_or(DEFAULT_EXPIRY_SECS); - - let (invoice, checkout_id) = - create_with_checkout(&node, &mdk_client, &description, req, expiry_secs).await?; - - let payment_hash = invoice.payment_hash().to_string(); - let expires_at = invoice.expires_at().map(|d| d.as_secs()).unwrap_or(0); - let amount_sat = invoice.amount_milli_satoshis().map(|m| m / 1000); - - let invoice_str = invoice.to_string(); - let metadata = InvoiceMetadata { - payment_hash: payment_hash.clone(), - external_id: req.external_id.clone(), - webhook_url: req.webhook_url.clone(), - checkout_id, - description: req.description.clone(), - invoice: Some(invoice_str.clone()), - amount_sat, - created_at: crate::time::seconds_since_epoch(), - expires_at, - }; - - metadata_store - .insert(&metadata) - .map_err(|e| AppError::Internal(format!("Failed to store invoice metadata: {e}")))?; - - Ok(Json(CreateInvoiceResponse { - amount_sat, - payment_hash, - serialized: invoice_str, - checkout_id: metadata.checkout_id, - })) -} - -async fn create_with_checkout( - node: &Node, - client: &MdkApiClient, - description: &Bolt11InvoiceDescription, - req: &CreateInvoiceRequest, - expiry_secs: u32, -) -> Result<(Bolt11Invoice, String), AppError> { - let products = req.product.as_ref().map(|p| vec![p.clone()]); - let metadata: Option = req + let metadata_json: Option = req .metadata .as_ref() .map(|s| serde_json::from_str(s)) @@ -114,7 +62,7 @@ async fn create_with_checkout( || req.customer_email.is_some() || req.customer_external_id.is_some() { - Some(CheckoutCustomer { + Some(Customer { name: req.customer_name.clone(), email: req.customer_email.clone(), external_id: req.customer_external_id.clone(), @@ -123,80 +71,41 @@ async fn create_with_checkout( None }; - let checkout_req = CreateCheckoutRequest { - node_id: node.node_id().to_string(), - amount: req.amount_sat, - currency: req.currency.clone().or_else(|| Some("SAT".into())), - products, + let params = CreateCheckoutParams { + amount_sat: req.amount_sat, + description, + expiry_seconds: req.expiry_seconds, + product: req.product.clone(), + currency: req.currency.clone(), success_url: req.success_url.clone(), - metadata, + metadata: metadata_json, customer, }; - let checkout = client.create_checkout(&checkout_req).await.map_err(|e| { - error!("MDK checkout/create failed: {e}"); - AppError::Internal(format!("Failed to create checkout: {e}")) - })?; - - info!( - "Created checkout {} (status: {})", - checkout.id, checkout.status - ); - - // Use the amount from the checkout (authoritative for product-based checkouts). - let amount_msat = match checkout.invoice_amount_sats { - Some(sats) => Some(sats * 1000), - None => req.amount_sat.map(|s| s * 1000), - }; - - let invoice = create_jit_invoice(node, amount_msat, description, expiry_secs)?; + let result = mdk_client.create_checkout(params).await?; - let scid = extract_scid(&invoice); - let payment_hash = invoice.payment_hash().to_string(); - let expires_at_iso = invoice - .expires_at() - .and_then(|d| { - DateTime::from_timestamp(d.as_secs() as i64, 0) - .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)) - }) - .unwrap_or_default(); - - let register_req = RegisterInvoiceRequest { - node_id: node.node_id().to_string(), - scid, - checkout_id: checkout.id.clone(), - invoice: invoice.to_string(), - payment_hash, - invoice_expires_at: expires_at_iso, + let metadata = InvoiceMetadata { + payment_hash: result.payment_hash.clone(), + external_id: req.external_id.clone(), + webhook_url: req.webhook_url.clone(), + checkout_id: result.checkout_id.clone(), + description: req.description.clone(), + invoice: Some(result.invoice.clone()), + amount_sat: result.amount_sat, + created_at: crate::daemon::time::seconds_since_epoch(), + expires_at: result.expires_at.unwrap_or(0), }; - let _registered = client.register_invoice(®ister_req).await.map_err(|e| { - error!("MDK checkout/registerInvoice failed: {e}"); - AppError::Internal(format!("Failed to register invoice: {e}")) - })?; - - Ok((invoice, checkout.id)) -} - -fn create_jit_invoice( - node: &Node, - amount_msat: Option, - description: &Bolt11InvoiceDescription, - expiry_secs: u32, -) -> Result { - node.bolt11_payment() - .receive_via_lsps4_jit_channel(amount_msat, description, expiry_secs) - .map_err(|e| AppError::Internal(format!("Failed to create JIT invoice: {e}"))) -} + metadata_store + .insert(&metadata) + .map_err(|e| AppError::Internal(format!("Failed to store invoice metadata: {e}")))?; -fn extract_scid(invoice: &Bolt11Invoice) -> String { - invoice - .route_hints() - .iter() - .flat_map(|hint| &hint.0) - .next() - .map(|hop| hop.short_channel_id.to_string()) - .unwrap_or_default() + Ok(Json(CreateInvoiceResponse { + amount_sat: result.amount_sat, + payment_hash: result.payment_hash, + serialized: result.invoice, + checkout_id: result.checkout_id, + })) } pub async fn handle_get_incoming_payment( @@ -206,8 +115,8 @@ pub async fn handle_get_incoming_payment( ) -> Result, AppError> { let metadata = metadata_store .get_by_payment_hash(&payment_hash) - .map_err(|e| AppError::Internal(format!("Failed to query metadata: {}", e)))? - .ok_or_else(|| AppError::NotFound(format!("Invoice {} not found", payment_hash)))?; + .map_err(|e| AppError::Internal(format!("Failed to query metadata: {e}")))? + .ok_or_else(|| AppError::NotFound(format!("Invoice {payment_hash} not found")))?; let hash_bytes = <[u8; 32]>::from_hex(&payment_hash) .map_err(|_| AppError::BadRequest("Invalid payment hash hex".into()))?; @@ -221,7 +130,7 @@ pub async fn handle_list_incoming_payments( metadata_store: Arc, params: &ListPaymentsRequest, ) -> Result>, AppError> { - let now = crate::time::seconds_since_epoch(); + let now = crate::daemon::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); @@ -265,7 +174,7 @@ pub async fn handle_list_outgoing_payments( metadata_store: Arc, params: &ListOutgoingPaymentsRequest, ) -> Result>, AppError> { - let now = crate::time::seconds_since_epoch(); + let now = crate::daemon::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; @@ -326,11 +235,10 @@ pub async fn handle_get_outgoing_payment( .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)))?; + .ok_or_else(|| AppError::NotFound(format!("Payment {payment_id} not found")))?; if details.direction != PaymentDirection::Outbound { return Err(AppError::NotFound(format!( - "Payment {} not found", - payment_id + "Payment {payment_id} not found" ))); } Ok(Json(payment_to_outgoing(&details))) @@ -410,7 +318,7 @@ fn enrich_metadata( None => (false, None, 0, None), }; - let now = crate::time::seconds_since_epoch(); + let now = crate::daemon::time::seconds_since_epoch(); let is_expired = !is_paid && metadata.expires_at > 0 && metadata.expires_at <= now; let fees = if is_paid { requested_sat.unwrap_or(0).saturating_sub(received_sat) @@ -447,7 +355,7 @@ fn extract_preimage(kind: &PaymentKind) -> Option { | PaymentKind::Bolt11Jit { preimage, .. } | PaymentKind::Bolt12Offer { preimage, .. } | PaymentKind::Bolt12Refund { preimage, .. } - | PaymentKind::Spontaneous { preimage, .. } => preimage.map(|p| format!("{}", p)), + | PaymentKind::Spontaneous { preimage, .. } => preimage.map(|p| format!("{p}")), PaymentKind::Onchain { .. } => None, } } @@ -596,7 +504,7 @@ mod tests { let req = test_req(Some("coffee"), None); assert!(matches!( parse_description(&req), - Ok(Bolt11InvoiceDescription::Direct(_)) + Ok(InvoiceDescription::Direct(_)) )); } @@ -606,7 +514,7 @@ mod tests { let req = test_req(None, Some(&hash)); assert!(matches!( parse_description(&req), - Ok(Bolt11InvoiceDescription::Hash(_)) + Ok(InvoiceDescription::Hash(_)) )); } diff --git a/src/api/mod.rs b/src/daemon/api/mod.rs similarity index 96% rename from src/api/mod.rs rename to src/daemon/api/mod.rs index 5ba17db..a04832b 100644 --- a/src/api/mod.rs +++ b/src/daemon/api/mod.rs @@ -22,10 +22,11 @@ use utoipa_scalar::{Scalar, Servable}; pub use auth::HttpAuth; -use crate::api::error::AppError; -use crate::mdk::client::MdkApiClient; -use crate::store::invoice_metadata::InvoiceMetadataStore; -use crate::types::{ +use mdk::client::MdkClient; + +use crate::daemon::api::error::AppError; +use crate::daemon::store::invoice_metadata::InvoiceMetadataStore; +use crate::daemon::types::{ ApiError, ChannelInfo, CloseChannelRequest, CreateInvoiceRequest, CreateInvoiceResponse, DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, GetBalanceResponse, GetInfoResponse, IncomingPaymentResponse, ListOutgoingPaymentsRequest, @@ -37,7 +38,7 @@ pub struct AppState { pub node: Arc, pub metadata_store: Arc, pub http_auth: HttpAuth, - pub mdk_client: Arc, + pub mdk_client: Arc, pub event_tx: broadcast::Sender, } @@ -107,7 +108,7 @@ pub fn router(state: AppState) -> Router { #[cfg(feature = "demo")] let router = { - const DEMO_HTML: &str = include_str!("../../wallet.html"); + const DEMO_HTML: &str = include_str!("../../../wallet.html"); router.route( "/", axum::routing::get(|| async { axum::response::Html(DEMO_HTML) }), @@ -231,7 +232,7 @@ async fn create_invoice( State(state): State, Form(req): Form, ) -> Result, AppError> { - invoices::handle_create_invoice(state.node, state.metadata_store, state.mdk_client, &req).await + invoices::handle_create_invoice(state.mdk_client, state.metadata_store, &req).await } #[utoipa::path( diff --git a/src/api/onchain.rs b/src/daemon/api/onchain.rs similarity index 85% rename from src/api/onchain.rs rename to src/daemon/api/onchain.rs index 0cc0b1a..60edcb2 100644 --- a/src/api/onchain.rs +++ b/src/daemon/api/onchain.rs @@ -4,9 +4,9 @@ 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; +use crate::daemon::api::error::AppError; +use crate::daemon::store::invoice_metadata::{InvoiceMetadataStore, OutgoingSendRecord}; +use crate::daemon::types::SendToAddressRequest; pub async fn handle_send_to_address( node: Arc, @@ -41,7 +41,7 @@ pub async fn handle_send_to_address( address: req.address.clone(), amount_sat: req.amount_sat, fee_sat: None, - created_at: crate::time::seconds_since_epoch(), + created_at: crate::daemon::time::seconds_since_epoch(), }; if let Err(e) = metadata_store.insert_outgoing_send(&record) { error!("Failed to store outgoing send: {e}"); diff --git a/src/api/websocket.rs b/src/daemon/api/websocket.rs similarity index 99% rename from src/api/websocket.rs rename to src/daemon/api/websocket.rs index 4395b54..000705c 100644 --- a/src/api/websocket.rs +++ b/src/daemon/api/websocket.rs @@ -6,7 +6,7 @@ use axum::http::{HeaderMap, StatusCode}; use axum::response::Response; use tokio::sync::broadcast; -use crate::api::auth::HttpAuth; +use crate::daemon::api::auth::HttpAuth; #[derive(Clone)] pub struct WsState { diff --git a/src/config.rs b/src/daemon/config.rs similarity index 52% rename from src/config.rs rename to src/daemon/config.rs index 1e77b15..1043ab1 100644 --- a/src/config.rs +++ b/src/daemon/config.rs @@ -140,132 +140,6 @@ pub fn get_default_data_dir() -> Option { } } -pub struct LspInfra { - pub chain_source: ChainSource, - pub lsp_node_id: &'static str, - pub lsp_address: &'static str, - pub mdk_api_base_url: &'static str, - pub vss_url: &'static str, -} - -impl LspInfra { - pub fn for_network(network: Network) -> Option { - match network { - Network::Bitcoin => Some(LspInfra { - chain_source: ChainSource::Esplora("https://esplora.moneydevkit.com/api"), - lsp_node_id: "02a63339cc6b913b6330bd61b2f469af8785a6011a6305bb102298a8e76697473b", - lsp_address: "lsp.moneydevkit.com:9735", - mdk_api_base_url: "https://moneydevkit.com/rpc", - vss_url: "https://vss.moneydevkit.com/vss", - }), - Network::Signet => Some(LspInfra { - chain_source: ChainSource::Esplora("https://mutinynet.com/api"), - lsp_node_id: "03fd9a377576df94cc7e458471c43c400630655083dee89df66c6ad38d1b7acffd", - lsp_address: "lsp.staging.moneydevkit.com:9735", - mdk_api_base_url: "https://staging.moneydevkit.com/rpc", - vss_url: "https://vss.staging.moneydevkit.com/vss", - }), - _ => None, - } - } -} - -pub enum ChainSource { - Esplora(&'static str), - Bitcoind { - rpc_host: String, - rpc_port: u16, - rpc_user: String, - rpc_password: String, - }, -} - -pub enum NetworkInfra { - Production(LspInfra), - Regtest { - chain_source: ChainSource, - lsp_node_id: String, - lsp_address: String, - mdk_api_base_url: String, - vss_url: String, - }, -} - -impl NetworkInfra { - pub fn resolve(network: Network) -> io::Result { - match LspInfra::for_network(network) { - Some(infra) => Ok(NetworkInfra::Production(infra)), - None => { - let rpc_port_str = env_required("MDK_BITCOIND_RPC_PORT")?; - let rpc_port: u16 = rpc_port_str.parse().map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("MDK_BITCOIND_RPC_PORT is not a valid port: {rpc_port_str}"), - ) - })?; - Ok(NetworkInfra::Regtest { - chain_source: ChainSource::Bitcoind { - rpc_host: env_required("MDK_BITCOIND_RPC_HOST")?, - rpc_port, - rpc_user: env_required("MDK_BITCOIND_RPC_USER")?, - rpc_password: env_required("MDK_BITCOIND_RPC_PASSWORD")?, - }, - lsp_node_id: env_required("MDK_LSP_NODE_ID")?, - lsp_address: env_required("MDK_LSP_ADDRESS")?, - mdk_api_base_url: env_required("MDK_API_BASE_URL")?, - vss_url: env_required("MDK_VSS_URL")?, - }) - } - } - } - - pub fn chain_source(&self) -> &ChainSource { - match self { - NetworkInfra::Production(lsp_infra) => &lsp_infra.chain_source, - NetworkInfra::Regtest { chain_source, .. } => chain_source, - } - } - - pub fn lsp_node_id(&self) -> &str { - match self { - NetworkInfra::Production(lsp_infra) => lsp_infra.lsp_node_id, - NetworkInfra::Regtest { lsp_node_id, .. } => lsp_node_id, - } - } - - pub fn lsp_address(&self) -> &str { - match self { - NetworkInfra::Production(lsp_infra) => lsp_infra.lsp_address, - NetworkInfra::Regtest { lsp_address, .. } => lsp_address, - } - } - - pub fn mdk_api_base_url(&self) -> &str { - match self { - NetworkInfra::Production(lsp_infra) => lsp_infra.mdk_api_base_url, - NetworkInfra::Regtest { - mdk_api_base_url, .. - } => mdk_api_base_url, - } - } - - pub fn vss_url(&self) -> &str { - match self { - NetworkInfra::Production(lsp_infra) => lsp_infra.vss_url, - NetworkInfra::Regtest { vss_url, .. } => vss_url, - } - } -} - -fn env_required(name: &str) -> io::Result { - std::env::var(name).map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("{name} environment variable is required for regtest"), - ) - }) -} - fn missing(field: &str) -> io::Error { io::Error::new( io::ErrorKind::InvalidInput, diff --git a/src/daemon/event_loop.rs b/src/daemon/event_loop.rs new file mode 100644 index 0000000..2557de1 --- /dev/null +++ b/src/daemon/event_loop.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use log::{error, info, warn}; +use tokio::sync::broadcast; + +use mdk::client::MdkClient; +use mdk::types::MdkEvent; + +use crate::daemon::store::invoice_metadata::InvoiceMetadataStore; +use crate::daemon::time; +use crate::daemon::types::WebhookEvent; +use crate::daemon::webhook::dispatcher::spawn_webhook_delivery; + +pub async fn run_event_loop( + mdk_client: Arc, + metadata_store: Arc, + webhook_secret: Vec, + http_client: reqwest::Client, + event_tx: broadcast::Sender, +) { + let mut rx = mdk_client.subscribe(); + loop { + match rx.recv().await { + Ok(event) => { + handle_event( + &event, + &metadata_store, + &webhook_secret, + &http_client, + &event_tx, + ); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("Daemon event handler lagged, missed {n} events"); + } + Err(broadcast::error::RecvError::Closed) => { + info!("MdkClient event channel closed, stopping daemon event loop"); + break; + } + } + } +} + +fn handle_event( + event: &MdkEvent, + metadata_store: &InvoiceMetadataStore, + webhook_secret: &[u8], + http_client: &reqwest::Client, + event_tx: &broadcast::Sender, +) { + let MdkEvent::PaymentReceived { + payment_hash, + amount_sats, + } = event + else { + return; + }; + + let metadata = match metadata_store.get_by_payment_hash(payment_hash) { + Ok(Some(m)) => m, + Ok(None) => return, + Err(e) => { + error!("Failed to look up invoice metadata: {e}"); + return; + } + }; + + if let Err(e) = metadata_store.mark_paid(payment_hash) { + error!("Failed to mark payment paid: {e}"); + } + + let webhook_event = WebhookEvent::PaymentReceived { + payment_hash: payment_hash.clone(), + amount_msat: amount_sats * 1000, + external_id: metadata.external_id.clone(), + timestamp: time::seconds_since_epoch(), + }; + + if let Ok(json) = serde_json::to_string(&webhook_event) { + let _ = event_tx.send(json); + } + + if let Some(webhook_url) = metadata.webhook_url { + spawn_webhook_delivery( + http_client.clone(), + webhook_url, + webhook_secret.to_vec(), + webhook_event, + ); + } +} diff --git a/src/expiry.rs b/src/daemon/expiry.rs similarity index 89% rename from src/expiry.rs rename to src/daemon/expiry.rs index c3f637a..bf0131d 100644 --- a/src/expiry.rs +++ b/src/daemon/expiry.rs @@ -3,10 +3,10 @@ use std::time::Duration; use log::{error, info}; -use crate::store::invoice_metadata::InvoiceMetadataStore; -use crate::time; -use crate::types::WebhookEvent; -use crate::webhook::dispatcher::spawn_webhook_delivery; +use crate::daemon::store::invoice_metadata::InvoiceMetadataStore; +use crate::daemon::time; +use crate::daemon::types::WebhookEvent; +use crate::daemon::webhook::dispatcher::spawn_webhook_delivery; const POLL_INTERVAL: Duration = Duration::from_secs(30); diff --git a/src/logger.rs b/src/daemon/logger.rs similarity index 100% rename from src/logger.rs rename to src/daemon/logger.rs diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..6339737 --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,10 @@ +pub mod api; +pub mod config; +pub mod event_loop; +pub mod expiry; +pub mod logger; +pub mod secret; +pub mod store; +pub mod time; +pub mod types; +pub mod webhook; diff --git a/src/secret.rs b/src/daemon/secret.rs similarity index 100% rename from src/secret.rs rename to src/daemon/secret.rs diff --git a/src/store/invoice_metadata.rs b/src/daemon/store/invoice_metadata.rs similarity index 100% rename from src/store/invoice_metadata.rs rename to src/daemon/store/invoice_metadata.rs diff --git a/src/store/mod.rs b/src/daemon/store/mod.rs similarity index 100% rename from src/store/mod.rs rename to src/daemon/store/mod.rs diff --git a/src/time.rs b/src/daemon/time.rs similarity index 100% rename from src/time.rs rename to src/daemon/time.rs diff --git a/src/types.rs b/src/daemon/types.rs similarity index 100% rename from src/types.rs rename to src/daemon/types.rs diff --git a/src/webhook/dispatcher.rs b/src/daemon/webhook/dispatcher.rs similarity index 98% rename from src/webhook/dispatcher.rs rename to src/daemon/webhook/dispatcher.rs index b7587ad..0bc777d 100644 --- a/src/webhook/dispatcher.rs +++ b/src/daemon/webhook/dispatcher.rs @@ -5,7 +5,7 @@ use hmac::{Hmac, Mac}; use log::{error, info}; use sha2::Sha256; -use crate::types::WebhookEvent; +use crate::daemon::types::WebhookEvent; type HmacSha256 = Hmac; diff --git a/src/webhook/mod.rs b/src/daemon/webhook/mod.rs similarity index 100% rename from src/webhook/mod.rs rename to src/daemon/webhook/mod.rs diff --git a/src/event_loop.rs b/src/event_loop.rs deleted file mode 100644 index f632880..0000000 --- a/src/event_loop.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::sync::Arc; - -use ldk_node::{Event, Node}; -use log::{error, info}; -use tokio::sync::broadcast; - -use crate::mdk::client::MdkApiClient; -use crate::mdk::types::{PaymentEntry, PaymentReceivedRequest}; -use crate::store::invoice_metadata::InvoiceMetadataStore; -use crate::time; -use crate::types::WebhookEvent; -use crate::webhook::dispatcher::spawn_webhook_delivery; - -pub async fn run_event_loop( - node: Arc, - metadata_store: Arc, - webhook_secret: Vec, - http_client: reqwest::Client, - mdk_client: Arc, - event_tx: broadcast::Sender, -) { - loop { - let event = node.next_event_async().await; - match event { - Event::PaymentReceived { - payment_hash, - amount_msat, - .. - } => { - info!( - "PAYMENT_RECEIVED: hash {}, amount_msat {}", - payment_hash, amount_msat - ); - - if let Err(e) = node.event_handled() { - error!("Failed to mark event as handled: {e}"); - } - - // Trigger webhook and MDK notification if registered for this payment hash. - let hash_str = payment_hash.to_string(); - match metadata_store.get_by_payment_hash(&hash_str) { - Ok(Some(metadata)) => { - if let Err(e) = metadata_store.mark_paid(&hash_str) { - error!("Failed to mark payment paid: {e}"); - } - - let event = WebhookEvent::PaymentReceived { - payment_hash: hash_str.clone(), - amount_msat, - external_id: metadata.external_id.clone(), - timestamp: time::seconds_since_epoch(), - }; - - if let Ok(json) = serde_json::to_string(&event) { - let _ = event_tx.send(json); - } - - if let Some(webhook_url) = metadata.webhook_url { - spawn_webhook_delivery( - http_client.clone(), - webhook_url, - webhook_secret.clone(), - event, - ); - } - - let client = Arc::clone(&mdk_client); - let hash = hash_str.clone(); - let amount_sats = amount_msat / 1000; - tokio::spawn(async move { - let req = PaymentReceivedRequest { - payments: vec![PaymentEntry { - payment_hash: hash.clone(), - amount_sats, - sandbox: false, - }], - }; - if let Err(e) = client.payment_received(&req).await { - error!( - "Failed to notify moneydevkit.com for payment {}: {e}", - hash - ); - } else { - info!( - "Notified moneydevkit.com of payment {} ({} sats)", - hash, amount_sats - ); - } - }); - } - Ok(None) => {} - Err(e) => error!("Failed to look up invoice metadata: {e}"), - } - } - Event::PaymentForwarded { - prev_channel_id, - next_channel_id, - total_fee_earned_msat, - outbound_amount_forwarded_msat, - .. - } => { - info!( - "PAYMENT_FORWARDED: outbound_msat {}, fee_msat: {}, in: {}, out: {}", - outbound_amount_forwarded_msat.unwrap_or(0), - total_fee_earned_msat.unwrap_or(0), - prev_channel_id, - next_channel_id - ); - if let Err(e) = node.event_handled() { - error!("Failed to mark event as handled: {e}"); - } - } - Event::ChannelPending { - channel_id, - counterparty_node_id, - .. - } => { - info!( - "CHANNEL_PENDING: {} from {}", - channel_id, counterparty_node_id - ); - if let Err(e) = node.event_handled() { - error!("Failed to mark event as handled: {e}"); - } - } - Event::ChannelReady { - channel_id, - counterparty_node_id, - .. - } => { - info!( - "CHANNEL_READY: {} from {:?}", - channel_id, counterparty_node_id - ); - if let Err(e) = node.event_handled() { - error!("Failed to mark event as handled: {e}"); - } - } - _ => { - if let Err(e) = node.event_handled() { - error!("Failed to mark event as handled: {e}"); - } - } - } - } -} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d9ae4aa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +mod mdk; + +pub use mdk::client; +pub use mdk::config; +pub use mdk::error; +pub use mdk::mdk_api; +pub use mdk::node; +pub use mdk::types; diff --git a/src/main.rs b/src/main.rs index 5eb7690..755e25f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,21 @@ -mod api; -mod config; -mod event_loop; -mod expiry; -mod logger; -mod mdk; -mod secret; -mod store; -mod time; -mod types; -mod webhook; - -use std::collections::HashMap; -use std::net::ToSocketAddrs; +mod daemon; + use std::path::PathBuf; -use std::str::FromStr; use std::sync::Arc; use clap::Parser; use hex::FromHex; -use ldk_node::bip39::Mnemonic; -use ldk_node::bitcoin::hashes::sha256; -use ldk_node::bitcoin::hashes::Hash; -use ldk_node::bitcoin::secp256k1::PublicKey; -use ldk_node::config::Config as LdkNodeConfig; -use ldk_node::lightning::ln::msgs::SocketAddress; -use ldk_node::Builder; use log::{error, info}; +use mdk::client::MdkClient; +use mdk::config::NetworkInfra; +use mdk::node::NodeConfig; use reqwest::{Client, Proxy}; use tokio::signal::unix::SignalKind; use tokio::sync::broadcast; -use crate::api::{AppState, HttpAuth}; -use crate::config::{get_default_data_dir, load_config, ChainSource, NetworkInfra}; -use crate::mdk::client::MdkApiClient; -use crate::store::invoice_metadata::InvoiceMetadataStore; +use daemon::api::{AppState, HttpAuth}; +use daemon::config::{get_default_data_dir, load_config}; +use daemon::store::invoice_metadata::InvoiceMetadataStore; #[derive(Parser)] #[command(version, about = "mdkd - MDK daemon")] @@ -81,7 +63,7 @@ fn main() { // Resolve secrets: FD flags take precedence, env vars as fallback. let resolve = |name, fd| { - secret::try_resolve(name, fd).unwrap_or_else(|e| { + daemon::secret::try_resolve(name, fd).unwrap_or_else(|e| { eprintln!("{e}"); std::process::exit(1); }) @@ -124,87 +106,7 @@ fn main() { std::process::exit(1); } - logger::init(config_file.log_level); - - // Optional SOCKS5 proxy for all outbound traffic. - let socks_proxy_url = args.socks_proxy; - let socks_proxy_addr = socks_proxy_url.as_ref().map(|raw| { - let host_port = raw - .strip_prefix("socks5://") - .or_else(|| raw.strip_prefix("socks5h://")) - .unwrap_or_else(|| { - eprintln!("SOCKS5 proxy url must start with socks5:// or socks5h://"); - std::process::exit(1); - }); - host_port - .to_socket_addrs() - .ok() - .and_then(|mut addrs| addrs.next()) - .unwrap_or_else(|| { - eprintln!("cannot resolve SOCKS5 proxy {}", host_port); - std::process::exit(1); - }) - }); - - if let Some(ref url) = socks_proxy_url { - info!("SOCKS5 proxy enabled: {}", url); - } - - let ldk_node_config = LdkNodeConfig { - storage_dir_path: network_dir.to_str().unwrap().to_string(), - listening_addresses: config_file.listening_addrs, - announcement_addresses: config_file.announcement_addrs, - network: config_file.network, - ..Default::default() - }; - - let mut builder = Builder::from_config(ldk_node_config); - builder.set_log_facade_logger(); - - if let Some(addr) = socks_proxy_addr { - builder.set_socks5_proxy(addr); - } - - if let Some(alias) = config_file.alias { - if let Err(e) = builder.set_node_alias(alias.to_string()) { - error!("Failed to set node alias: {e}"); - std::process::exit(1); - } - } - - match infra.chain_source() { - ChainSource::Esplora(server_url) => { - builder.set_chain_source_esplora(server_url.to_string(), None); - } - ChainSource::Bitcoind { - rpc_host, - rpc_port, - rpc_user, - rpc_password, - } => { - builder.set_chain_source_bitcoind_rpc( - rpc_host.clone(), - *rpc_port, - rpc_user.clone(), - rpc_password.clone(), - ); - } - } - - if let Some(url) = config_file.pathfinding_scores_source_url { - builder.set_pathfinding_scores_source(url); - } - - let lsp_pubkey = PublicKey::from_str(infra.lsp_node_id()).unwrap_or_else(|e| { - error!("Bad lsp_node_id: {e}"); - std::process::exit(1); - }); - let lsp_addr = SocketAddress::from_str(infra.lsp_address()).unwrap_or_else(|e| { - error!("Bad lsp_address: {e}"); - std::process::exit(1); - }); - builder.set_liquidity_source_lsps4(lsp_pubkey, lsp_addr); - info!("LSPS4 liquidity source: {}", infra.lsp_node_id()); + daemon::logger::init(config_file.log_level); let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -217,45 +119,24 @@ fn main() { } }; - builder.set_runtime(runtime.handle().clone()); - - let mnemonic = Mnemonic::parse(&mnemonic_phrase).unwrap_or_else(|e| { - error!("Invalid MDK_MNEMONIC: {e}"); - std::process::exit(1); - }); - builder.set_entropy_bip39_mnemonic(mnemonic.clone(), None); - - let store_id = derive_vss_identifier(&mnemonic); - info!( - "VSS store: {} (store_id={}...)", - infra.vss_url(), - &store_id[..16] - ); - - let node = match builder.build_with_vss_store_and_fixed_headers( - infra.vss_url().to_string(), - store_id, - HashMap::new(), - ) { - Ok(node) => Arc::new(node), - Err(e) => { - error!("Failed to build LDK Node: {e}"); - std::process::exit(1); - } - }; + let socks_proxy = args.socks_proxy; - let db_path = network_dir.join("mdkd.sqlite"); - let metadata_store = match InvoiceMetadataStore::new(&db_path) { - Ok(store) => Arc::new(store), - Err(e) => { - error!("Failed to create InvoiceMetadataStore: {e}"); - std::process::exit(1); - } + let node_config = NodeConfig { + network: config_file.network, + storage_dir_path: network_dir.to_str().unwrap().to_string(), + listening_addresses: config_file.listening_addrs, + announcement_addresses: config_file.announcement_addrs, + alias: config_file.alias.map(|a| a.to_string()), + socks_proxy: socks_proxy.clone(), + pathfinding_scores_source_url: config_file.pathfinding_scores_source_url, + mnemonic: mnemonic_phrase, + infra, }; + // Separate HTTP client for daemon concerns (webhooks, expiry monitor). let http_client = { let mut b = Client::builder(); - if let Some(ref proxy_url) = socks_proxy_url { + if let Some(ref proxy_url) = socks_proxy { let proxy = Proxy::all(proxy_url).unwrap_or_else(|e| { error!("Invalid SOCKS5 proxy for reqwest: {e}"); std::process::exit(1); @@ -268,22 +149,29 @@ fn main() { }) }; - let base_url = infra.mdk_api_base_url().to_string(); - info!("MDK platform integration enabled ({})", base_url); - let mdk_client = Arc::new(MdkApiClient::new( - http_client.clone(), - base_url, - mdk_access_token, - )); + let db_path = network_dir.join("mdkd.sqlite"); + let metadata_store = match InvoiceMetadataStore::new(&db_path) { + Ok(store) => Arc::new(store), + Err(e) => { + error!("Failed to create InvoiceMetadataStore: {e}"); + std::process::exit(1); + } + }; - info!("Starting up..."); - match node.start() { - Ok(()) => {} + let mdk_client = match MdkClient::new( + node_config, + mdk_access_token, + None, + Some(runtime.handle().clone()), + ) { + Ok(client) => Arc::new(client), Err(e) => { - error!("Failed to start LDK Node: {e}"); + error!("Failed to build MdkClient: {e}"); std::process::exit(1); } - } + }; + + let node = mdk_client.node(); let addrs = node .config() @@ -309,20 +197,27 @@ fn main() { } }; - let (event_tx, _) = broadcast::channel::(128); + if let Err(e) = mdk_client.start() { + error!("Failed to start MdkClient: {e}"); + std::process::exit(1); + } + + info!("Starting up..."); + + let (ws_tx, _) = broadcast::channel::(128); let app_state = AppState { - node: Arc::clone(&node), + node: mdk_client.node_arc(), metadata_store: Arc::clone(&metadata_store), http_auth: HttpAuth { full_password: full_password.clone(), read_only_password: read_only_password.clone(), }, mdk_client: mdk_client.clone(), - event_tx: event_tx.clone(), + event_tx: ws_tx.clone(), }; - let app = api::router(app_state); + let app = daemon::api::router(app_state); let listener = match tokio::net::TcpListener::bind(bind_addr).await { Ok(l) => l, @@ -339,23 +234,21 @@ fn main() { let expiry_client = http_client.clone(); tokio::spawn(async move { - expiry::run_expiry_monitor(expiry_metadata, expiry_secret, expiry_client).await; + daemon::expiry::run_expiry_monitor(expiry_metadata, expiry_secret, expiry_client).await; }); - let event_node = Arc::clone(&node); + let event_mdk = Arc::clone(&mdk_client); let event_metadata = Arc::clone(&metadata_store); let event_secret = webhook_secret; let event_client = http_client.clone(); - let event_mdk_client = mdk_client.clone(); tokio::spawn(async move { - event_loop::run_event_loop( - event_node, + daemon::event_loop::run_event_loop( + event_mdk, event_metadata, event_secret, event_client, - event_mdk_client, - event_tx, + ws_tx, ) .await; }); @@ -376,11 +269,8 @@ fn main() { } }); - node.stop().expect("Shutdown should always succeed."); + if let Err(e) = mdk_client.stop() { + error!("Error during shutdown: {e}"); + } info!("Shutdown complete."); } - -fn derive_vss_identifier(mnemonic: &Mnemonic) -> String { - let mnemonic_phrase = mnemonic.to_string(); - sha256::Hash::hash(mnemonic_phrase.as_bytes()).to_string() -} diff --git a/src/mdk/client.rs b/src/mdk/client.rs index d769f6d..ac7da01 100644 --- a/src/mdk/client.rs +++ b/src/mdk/client.rs @@ -1,116 +1,376 @@ -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; +use std::sync::Arc; -use super::types::*; +use chrono::{DateTime, SecondsFormat}; +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::{Event, Node}; +use log::{error, info}; +use reqwest::{Client, Proxy}; +use tokio::runtime::Handle; +use tokio::sync::broadcast; +use tokio_util::sync::CancellationToken; -#[derive(Clone)] -pub struct MdkApiClient { - http: reqwest::Client, - base_url: String, - access_token: String, -} +use crate::mdk::error::MdkError; +use crate::mdk::mdk_api::client::MdkApiClient; +use crate::mdk::mdk_api::types::{ + CheckoutCustomer, CreateCheckoutRequest, PaymentEntry, PaymentReceivedRequest, + RegisterInvoiceRequest, +}; +use crate::mdk::node::{build_node, NodeConfig}; +use crate::mdk::types::{CheckoutResult, CreateCheckoutParams, InvoiceDescription, MdkEvent}; -/// oRPC request envelope: `{ "json": , "meta": [...] }` -/// -/// The `meta` array tells the oRPC server which fields need special -/// deserialization. Date fields use type marker `1`: -/// `[[1, "fieldName"]]` -#[derive(Serialize)] -struct OrpcRequest { - json: T, - #[serde(skip_serializing_if = "Vec::is_empty")] - meta: Vec<(u8, &'static str)>, -} +const DEFAULT_EXPIRY_SECS: u32 = 3600; +const MAX_DESCRIPTION_LEN: usize = 128; -/// oRPC response envelope: `{ "json": }` -#[derive(Deserialize)] -struct OrpcResponse { - json: T, +/// Callback invoked for each translated MdkEvent. +/// Fires before the broadcast channel send, so handlers see events +/// even when no broadcast subscriber exists. +pub type EventHandler = Arc; + +pub struct MdkClient { + node: Arc, + api: Arc, + event_tx: broadcast::Sender, + event_handler: Option, + shutdown: CancellationToken, + handle: Handle, + /// Keeps the runtime alive when the library created it. + /// None when the caller provided a handle. + _runtime: Option>, } -impl MdkApiClient { - pub fn new(http: reqwest::Client, base_url: String, access_token: String) -> Self { - Self { - http, - base_url, +impl MdkClient { + /// Build the LDK node, HTTP client, and platform API client from config. + /// + /// `runtime` — pass `Some(handle)` to reuse an existing tokio runtime + /// (typical for Rust callers), or `None` to let the library create its own + /// (typical for language bindings). + /// + /// Does not start the node or event loop — call `start()` for that. + pub fn new( + config: NodeConfig, + access_token: String, + event_handler: Option, + runtime: Option, + ) -> Result { + let (handle, owned_runtime) = match runtime { + Some(h) => (h, None), + None => { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|e| MdkError::Node(format!("failed to create tokio runtime: {e}")))?; + let h = rt.handle().clone(); + (h, Some(Arc::new(rt))) + } + }; + + let api_base_url = config.infra.mdk_api_base_url.clone(); + let socks_proxy = config.socks_proxy.clone(); + + let node = build_node(config, handle.clone())?; + let http_client = build_http_client(socks_proxy.as_deref())?; + let api = Arc::new(MdkApiClient::new( + http_client.clone(), + api_base_url, access_token, - } + )); + + let (event_tx, _) = broadcast::channel(256); + Ok(Self { + node, + api, + event_tx, + event_handler, + shutdown: CancellationToken::new(), + handle, + _runtime: owned_runtime, + }) } - pub async fn create_checkout( - &self, - req: &CreateCheckoutRequest, - ) -> Result { - self.post("checkout/create", req, vec![]).await + /// Start the LDK node and spawn the internal event loop. + pub fn start(self: &Arc) -> Result<(), MdkError> { + self.node.start()?; + let this = Arc::clone(self); + self.handle.spawn(async move { + this.run_event_loop().await; + }); + Ok(()) } - pub async fn register_invoice( - &self, - req: &RegisterInvoiceRequest, - ) -> Result { - // invoiceExpiresAt is z.date() — tell oRPC to parse it as a Date. - let meta = vec![(1, "invoiceExpiresAt")]; - self.post("checkout/registerInvoice", req, meta).await + /// Cancel the event loop and stop the LDK node. + pub fn stop(&self) -> Result<(), MdkError> { + self.shutdown.cancel(); + self.node.stop()?; + Ok(()) } - pub async fn payment_received( - &self, - req: &PaymentReceivedRequest, - ) -> Result { - self.post("checkout/paymentReceived", req, vec![]).await + pub fn node(&self) -> &Node { + &self.node + } + + pub fn node_arc(&self) -> Arc { + Arc::clone(&self.node) + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + async fn run_event_loop(&self) { + loop { + tokio::select! { + _ = self.shutdown.cancelled() => break, + event = self.node.next_event_async() => { + let mdk_event = self.handle_ldk_event(&event).await; + + if let Err(e) = self.node.event_handled() { + error!("Failed to mark event as handled: {e}"); + } + + if let Some(ev) = mdk_event { + if let Some(handler) = &self.event_handler { + handler(ev.clone()); + } + let _ = self.event_tx.send(ev); + } + } + } + } + info!("Event loop stopped"); + } + + async fn handle_ldk_event(&self, event: &Event) -> Option { + match event { + Event::PaymentReceived { + payment_hash, + amount_msat, + .. + } => { + let hash = payment_hash.to_string(); + let amount_sats = amount_msat / 1000; + info!("PAYMENT_RECEIVED: hash {hash}, amount_sats {amount_sats}"); + + self.notify_payment_received(&hash, amount_sats).await; + + Some(MdkEvent::PaymentReceived { + payment_hash: hash, + amount_sats, + }) + } + Event::PaymentSuccessful { + payment_id, + payment_hash, + fee_paid_msat, + .. + } => { + info!("PAYMENT_SUCCESSFUL: hash {payment_hash}"); + Some(MdkEvent::PaymentSuccessful { + payment_id: format_payment_id(payment_id), + payment_hash: Some(payment_hash.to_string()), + fee_paid_sats: fee_paid_msat.map(|m| m / 1000), + }) + } + Event::PaymentFailed { + payment_id, + payment_hash, + reason, + .. + } => { + info!("PAYMENT_FAILED: hash {payment_hash:?}, reason: {reason:?}"); + Some(MdkEvent::PaymentFailed { + payment_id: format_payment_id(payment_id), + reason: reason.map(|r| format!("{r:?}")), + }) + } + Event::ChannelPending { + channel_id, + counterparty_node_id, + .. + } => { + info!("CHANNEL_PENDING: {channel_id} from {counterparty_node_id}"); + Some(MdkEvent::ChannelPending { + channel_id: channel_id.to_string(), + counterparty_node_id: counterparty_node_id.to_string(), + }) + } + Event::ChannelReady { + channel_id, + counterparty_node_id, + .. + } => { + info!("CHANNEL_READY: {channel_id} from {counterparty_node_id:?}"); + Some(MdkEvent::ChannelReady { + channel_id: channel_id.to_string(), + counterparty_node_id: counterparty_node_id + .map(|pk| pk.to_string()) + .unwrap_or_default(), + }) + } + Event::PaymentForwarded { + total_fee_earned_msat, + outbound_amount_forwarded_msat, + prev_channel_id, + next_channel_id, + .. + } => { + info!( + "PAYMENT_FORWARDED: outbound_msat {}, fee_msat {}, in: {prev_channel_id}, out: {next_channel_id}", + outbound_amount_forwarded_msat.unwrap_or(0), + total_fee_earned_msat.unwrap_or(0), + ); + Some(MdkEvent::PaymentForwarded { + fee_earned_sats: total_fee_earned_msat.map(|m| m / 1000), + }) + } + _ => None, + } + } + + async fn notify_payment_received(&self, payment_hash: &str, amount_sats: u64) { + let req = PaymentReceivedRequest { + payments: vec![PaymentEntry { + payment_hash: payment_hash.to_string(), + amount_sats, + sandbox: false, + }], + }; + match self.api.payment_received(&req).await { + Ok(_) => { + info!("Notified moneydevkit.com of payment {payment_hash} ({amount_sats} sats)") + } + Err(e) => error!("Failed to notify moneydevkit.com for payment {payment_hash}: {e}"), + } } - async fn post( + pub async fn create_checkout( &self, - path: &str, - body: &Req, - meta: Vec<(u8, &'static str)>, - ) -> Result { - let url = format!("{}/{}", self.base_url, path); - let envelope = OrpcRequest { json: body, meta }; - let response = self - .http - .post(&url) - .header("x-api-key", &self.access_token) - .json(&envelope) - .send() - .await?; - - let status = response.status(); - let bytes = response.bytes().await?; - - if status.is_success() { - let resp: OrpcResponse = serde_json::from_slice(&bytes).map_err(|e| { - MdkApiError::Deserialize(format!("{e} (body: {})", String::from_utf8_lossy(&bytes))) + params: CreateCheckoutParams, + ) -> Result { + let description = to_bolt11_description(¶ms.description)?; + let expiry_secs = params.expiry_seconds.unwrap_or(DEFAULT_EXPIRY_SECS); + + let customer = params.customer.map(|c| CheckoutCustomer { + name: c.name, + email: c.email, + external_id: c.external_id, + }); + + let checkout_req = CreateCheckoutRequest { + node_id: self.node.node_id().to_string(), + amount: params.amount_sat, + currency: params.currency.or_else(|| Some("SAT".into())), + products: params.product.map(|p| vec![p]), + success_url: params.success_url, + metadata: params.metadata, + customer, + }; + + let checkout = self.api.create_checkout(&checkout_req).await.map_err(|e| { + error!("MDK checkout/create failed: {e}"); + MdkError::from(e) + })?; + + info!( + "Created checkout {} (status: {})", + checkout.id, checkout.status + ); + + let amount_msat = match checkout.invoice_amount_sats { + Some(sats) => Some(sats * 1000), + None => params.amount_sat.map(|s| s * 1000), + }; + + let invoice = self + .node + .bolt11_payment() + .receive_via_lsps4_jit_channel(amount_msat, &description, expiry_secs) + .map_err(|e| MdkError::Node(format!("failed to create JIT invoice: {e}")))?; + + let scid = extract_scid(&invoice); + let payment_hash = invoice.payment_hash().to_string(); + let expires_at = invoice.expires_at().map(|d| d.as_secs()); + let expires_at_iso = expires_at + .and_then(|secs| { + DateTime::from_timestamp(secs as i64, 0) + .map(|dt| dt.to_rfc3339_opts(SecondsFormat::Secs, true)) + }) + .unwrap_or_default(); + + let register_req = RegisterInvoiceRequest { + node_id: self.node.node_id().to_string(), + scid, + checkout_id: checkout.id.clone(), + invoice: invoice.to_string(), + payment_hash: payment_hash.clone(), + invoice_expires_at: expires_at_iso, + }; + + self.api + .register_invoice(®ister_req) + .await + .map_err(|e| { + error!("MDK checkout/registerInvoice failed: {e}"); + MdkError::from(e) })?; - Ok(resp.json) - } else { - // oRPC errors also use the { "json": { ... } } envelope. - match serde_json::from_slice::(&bytes) { - Ok(val) => { - let err = val.get("json").unwrap_or(&val); - let code = err - .get("code") - .and_then(|v| v.as_str()) - .unwrap_or("UNKNOWN") - .to_string(); - let message = err - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("unknown error") - .to_string(); - Err(MdkApiError::Api { - code, - message, - status: status.as_u16(), - }) - } - Err(_) => Err(MdkApiError::Api { - code: "UNKNOWN".into(), - message: String::from_utf8_lossy(&bytes).into_owned(), - status: status.as_u16(), - }), + + let amount_sat = invoice.amount_milli_satoshis().map(|m| m / 1000); + + Ok(CheckoutResult { + checkout_id: checkout.id, + invoice: invoice.to_string(), + payment_hash, + amount_sat, + expires_at, + }) + } +} + +fn build_http_client(socks_proxy: Option<&str>) -> Result { + let mut builder = Client::builder(); + if let Some(proxy_url) = socks_proxy { + let proxy = Proxy::all(proxy_url) + .map_err(|e| MdkError::InvalidInput(format!("invalid SOCKS5 proxy for HTTP: {e}")))?; + builder = builder.proxy(proxy); + } + builder + .build() + .map_err(|e| MdkError::Network(format!("failed to build HTTP client: {e}"))) +} + +fn to_bolt11_description(desc: &InvoiceDescription) -> Result { + match desc { + InvoiceDescription::Direct(text) => { + if text.len() > MAX_DESCRIPTION_LEN { + return Err(MdkError::InvalidInput(format!( + "description too long (max {MAX_DESCRIPTION_LEN} characters)" + ))); } + let d = Description::new(text.clone()) + .map_err(|e| MdkError::InvalidInput(format!("invalid description: {e}")))?; + Ok(Bolt11InvoiceDescription::Direct(d)) } + InvoiceDescription::Hash(bytes) => Ok(Bolt11InvoiceDescription::Hash(Sha256( + sha256::Hash::from_byte_array(*bytes), + ))), + } +} + +fn extract_scid(invoice: &Bolt11Invoice) -> String { + invoice + .route_hints() + .iter() + .flat_map(|hint| &hint.0) + .next() + .map(|hop| hop.short_channel_id.to_string()) + .unwrap_or_default() +} + +fn format_payment_id(id: &Option) -> String { + match id { + Some(pid) => pid.0.iter().map(|b| format!("{b:02x}")).collect(), + None => "unknown".into(), } } diff --git a/src/mdk/config.rs b/src/mdk/config.rs new file mode 100644 index 0000000..0e5467b --- /dev/null +++ b/src/mdk/config.rs @@ -0,0 +1,78 @@ +use std::io; + +use ldk_node::bitcoin::Network; + +pub enum ChainSource { + Esplora(String), + Bitcoind { + rpc_host: String, + rpc_port: u16, + rpc_user: String, + rpc_password: String, + }, +} + +pub struct NetworkInfra { + pub chain_source: ChainSource, + pub lsp_node_id: String, + pub lsp_address: String, + pub mdk_api_base_url: String, + pub vss_url: String, +} + +impl NetworkInfra { + pub fn resolve(network: Network) -> io::Result { + match network { + Network::Bitcoin => Ok(Self { + chain_source: ChainSource::Esplora("https://esplora.moneydevkit.com/api".into()), + lsp_node_id: "02a63339cc6b913b6330bd61b2f469af8785a6011a6305bb102298a8e76697473b" + .into(), + lsp_address: "lsp.moneydevkit.com:9735".into(), + mdk_api_base_url: "https://moneydevkit.com/rpc".into(), + vss_url: "https://vss.moneydevkit.com/vss".into(), + }), + Network::Signet => Ok(Self { + chain_source: ChainSource::Esplora("https://mutinynet.com/api".into()), + lsp_node_id: "03fd9a377576df94cc7e458471c43c400630655083dee89df66c6ad38d1b7acffd" + .into(), + lsp_address: "lsp.staging.moneydevkit.com:9735".into(), + mdk_api_base_url: "https://staging.moneydevkit.com/rpc".into(), + vss_url: "https://vss.staging.moneydevkit.com/vss".into(), + }), + Network::Testnet | Network::Testnet4 => Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("unsupported network: {network}"), + )), + _ => { + let rpc_port_str = env_required("MDK_BITCOIND_RPC_PORT")?; + let rpc_port: u16 = rpc_port_str.parse().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("MDK_BITCOIND_RPC_PORT is not a valid port: {rpc_port_str}"), + ) + })?; + Ok(Self { + chain_source: ChainSource::Bitcoind { + rpc_host: env_required("MDK_BITCOIND_RPC_HOST")?, + rpc_port, + rpc_user: env_required("MDK_BITCOIND_RPC_USER")?, + rpc_password: env_required("MDK_BITCOIND_RPC_PASSWORD")?, + }, + lsp_node_id: env_required("MDK_LSP_NODE_ID")?, + lsp_address: env_required("MDK_LSP_ADDRESS")?, + mdk_api_base_url: env_required("MDK_API_BASE_URL")?, + vss_url: env_required("MDK_VSS_URL")?, + }) + } + } + } +} + +fn env_required(name: &str) -> io::Result { + std::env::var(name).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{name} environment variable is required for regtest"), + ) + }) +} diff --git a/src/mdk/error.rs b/src/mdk/error.rs new file mode 100644 index 0000000..ae72fb9 --- /dev/null +++ b/src/mdk/error.rs @@ -0,0 +1,64 @@ +use std::fmt; + +use crate::mdk::mdk_api::types::MdkApiError; + +#[derive(Debug)] +pub enum MdkError { + InvalidInput(String), + Node(String), + Platform { + code: String, + message: String, + status: u16, + }, + Network(String), + NotFound(String), +} + +impl fmt::Display for MdkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MdkError::InvalidInput(msg) => write!(f, "invalid input: {msg}"), + MdkError::Node(msg) => write!(f, "node error: {msg}"), + MdkError::Platform { + code, + message, + status, + } => write!(f, "platform API error ({status}): [{code}] {message}"), + MdkError::Network(msg) => write!(f, "network error: {msg}"), + MdkError::NotFound(msg) => write!(f, "not found: {msg}"), + } + } +} + +impl std::error::Error for MdkError {} + +impl From for MdkError { + fn from(e: ldk_node::NodeError) -> Self { + MdkError::Node(e.to_string()) + } +} + +impl From for MdkError { + fn from(e: ldk_node::BuildError) -> Self { + MdkError::Node(e.to_string()) + } +} + +impl From for MdkError { + fn from(e: MdkApiError) -> Self { + match e { + MdkApiError::Network(inner) => MdkError::Network(inner.to_string()), + MdkApiError::Api { + code, + message, + status, + } => MdkError::Platform { + code, + message, + status, + }, + MdkApiError::Deserialize(msg) => MdkError::Network(msg), + } + } +} diff --git a/src/mdk/mdk_api/client.rs b/src/mdk/mdk_api/client.rs new file mode 100644 index 0000000..d769f6d --- /dev/null +++ b/src/mdk/mdk_api/client.rs @@ -0,0 +1,116 @@ +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use super::types::*; + +#[derive(Clone)] +pub struct MdkApiClient { + http: reqwest::Client, + base_url: String, + access_token: String, +} + +/// oRPC request envelope: `{ "json": , "meta": [...] }` +/// +/// The `meta` array tells the oRPC server which fields need special +/// deserialization. Date fields use type marker `1`: +/// `[[1, "fieldName"]]` +#[derive(Serialize)] +struct OrpcRequest { + json: T, + #[serde(skip_serializing_if = "Vec::is_empty")] + meta: Vec<(u8, &'static str)>, +} + +/// oRPC response envelope: `{ "json": }` +#[derive(Deserialize)] +struct OrpcResponse { + json: T, +} + +impl MdkApiClient { + pub fn new(http: reqwest::Client, base_url: String, access_token: String) -> Self { + Self { + http, + base_url, + access_token, + } + } + + pub async fn create_checkout( + &self, + req: &CreateCheckoutRequest, + ) -> Result { + self.post("checkout/create", req, vec![]).await + } + + pub async fn register_invoice( + &self, + req: &RegisterInvoiceRequest, + ) -> Result { + // invoiceExpiresAt is z.date() — tell oRPC to parse it as a Date. + let meta = vec![(1, "invoiceExpiresAt")]; + self.post("checkout/registerInvoice", req, meta).await + } + + pub async fn payment_received( + &self, + req: &PaymentReceivedRequest, + ) -> Result { + self.post("checkout/paymentReceived", req, vec![]).await + } + + async fn post( + &self, + path: &str, + body: &Req, + meta: Vec<(u8, &'static str)>, + ) -> Result { + let url = format!("{}/{}", self.base_url, path); + let envelope = OrpcRequest { json: body, meta }; + let response = self + .http + .post(&url) + .header("x-api-key", &self.access_token) + .json(&envelope) + .send() + .await?; + + let status = response.status(); + let bytes = response.bytes().await?; + + if status.is_success() { + let resp: OrpcResponse = serde_json::from_slice(&bytes).map_err(|e| { + MdkApiError::Deserialize(format!("{e} (body: {})", String::from_utf8_lossy(&bytes))) + })?; + Ok(resp.json) + } else { + // oRPC errors also use the { "json": { ... } } envelope. + match serde_json::from_slice::(&bytes) { + Ok(val) => { + let err = val.get("json").unwrap_or(&val); + let code = err + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("UNKNOWN") + .to_string(); + let message = err + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error") + .to_string(); + Err(MdkApiError::Api { + code, + message, + status: status.as_u16(), + }) + } + Err(_) => Err(MdkApiError::Api { + code: "UNKNOWN".into(), + message: String::from_utf8_lossy(&bytes).into_owned(), + status: status.as_u16(), + }), + } + } + } +} diff --git a/src/mdk/mdk_api/mod.rs b/src/mdk/mdk_api/mod.rs new file mode 100644 index 0000000..9251ae9 --- /dev/null +++ b/src/mdk/mdk_api/mod.rs @@ -0,0 +1,2 @@ +pub mod client; +pub mod types; diff --git a/src/mdk/mdk_api/types.rs b/src/mdk/mdk_api/types.rs new file mode 100644 index 0000000..620c5e7 --- /dev/null +++ b/src/mdk/mdk_api/types.rs @@ -0,0 +1,113 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +// --- Requests --- + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateCheckoutRequest { + pub node_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub products: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub success_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub customer: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckoutCustomer { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_id: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterInvoiceRequest { + pub node_id: String, + pub scid: String, + pub checkout_id: String, + pub invoice: String, + pub payment_hash: String, + pub invoice_expires_at: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentReceivedRequest { + pub payments: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentEntry { + pub payment_hash: String, + pub amount_sats: u64, + pub sandbox: bool, +} + +// --- Responses --- + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct Checkout { + pub id: String, + pub status: String, + pub invoice_amount_sats: Option, + pub invoice_scid: Option, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct PaymentReceivedResponse { + pub ok: bool, +} + +// --- Errors --- + +#[derive(Debug)] +pub enum MdkApiError { + Network(reqwest::Error), + Api { + code: String, + message: String, + status: u16, + }, + Deserialize(String), +} + +impl fmt::Display for MdkApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MdkApiError::Network(e) => write!(f, "network error: {e}"), + MdkApiError::Api { + code, + message, + status, + } => { + write!(f, "API error {status} [{code}]: {message}") + } + MdkApiError::Deserialize(msg) => write!(f, "deserialize error: {msg}"), + } + } +} + +impl From for MdkApiError { + fn from(e: reqwest::Error) -> Self { + MdkApiError::Network(e) + } +} diff --git a/src/mdk/mod.rs b/src/mdk/mod.rs index 9251ae9..a8981b8 100644 --- a/src/mdk/mod.rs +++ b/src/mdk/mod.rs @@ -1,2 +1,6 @@ pub mod client; +pub mod config; +pub mod error; +pub mod mdk_api; +pub mod node; pub mod types; diff --git a/src/mdk/node.rs b/src/mdk/node.rs new file mode 100644 index 0000000..246f5d3 --- /dev/null +++ b/src/mdk/node.rs @@ -0,0 +1,129 @@ +use std::collections::HashMap; +use std::net::ToSocketAddrs; +use std::str::FromStr; +use std::sync::Arc; + +use ldk_node::bip39::Mnemonic; +use ldk_node::bitcoin::hashes::sha256; +use ldk_node::bitcoin::hashes::Hash; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Network; +use ldk_node::config::Config as LdkNodeConfig; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::{Builder, Node}; +use log::info; + +use crate::mdk::config::{ChainSource, NetworkInfra}; +use crate::mdk::error::MdkError; + +pub struct NodeConfig { + pub network: Network, + pub storage_dir_path: String, + pub listening_addresses: Option>, + pub announcement_addresses: Option>, + pub alias: Option, + pub socks_proxy: Option, + pub pathfinding_scores_source_url: Option, + pub mnemonic: String, + pub infra: NetworkInfra, +} + +pub fn build_node( + config: NodeConfig, + runtime: tokio::runtime::Handle, +) -> Result, MdkError> { + let ldk_config = LdkNodeConfig { + storage_dir_path: config.storage_dir_path.clone(), + listening_addresses: config.listening_addresses, + announcement_addresses: config.announcement_addresses, + network: config.network, + ..Default::default() + }; + + let mut builder = Builder::from_config(ldk_config); + builder.set_log_facade_logger(); + + if let Some(ref proxy_url) = config.socks_proxy { + let addr = resolve_socks_proxy(proxy_url)?; + builder.set_socks5_proxy(addr); + info!("SOCKS5 proxy enabled: {}", proxy_url); + } + + if let Some(alias) = config.alias { + builder + .set_node_alias(alias.to_string()) + .map_err(|e| MdkError::InvalidInput(format!("invalid node alias: {e}")))?; + } + + let infra = config.infra; + + match &infra.chain_source { + ChainSource::Esplora(server_url) => { + builder.set_chain_source_esplora(server_url.clone(), None); + } + ChainSource::Bitcoind { + rpc_host, + rpc_port, + rpc_user, + rpc_password, + } => { + builder.set_chain_source_bitcoind_rpc( + rpc_host.clone(), + *rpc_port, + rpc_user.clone(), + rpc_password.clone(), + ); + } + } + + if let Some(url) = config.pathfinding_scores_source_url { + builder.set_pathfinding_scores_source(url); + } + + let lsp_pubkey = PublicKey::from_str(&infra.lsp_node_id) + .map_err(|e| MdkError::InvalidInput(format!("bad lsp_node_id: {e}")))?; + let lsp_addr = SocketAddress::from_str(&infra.lsp_address) + .map_err(|e| MdkError::InvalidInput(format!("bad lsp_address: {e}")))?; + builder.set_liquidity_source_lsps4(lsp_pubkey, lsp_addr); + info!("LSPS4 liquidity source: {}", infra.lsp_node_id); + + builder.set_runtime(runtime); + + let mnemonic = Mnemonic::parse(&config.mnemonic) + .map_err(|e| MdkError::InvalidInput(format!("invalid mnemonic: {e}")))?; + builder.set_entropy_bip39_mnemonic(mnemonic.clone(), None); + + let store_id = derive_vss_identifier(&mnemonic); + info!( + "VSS store: {} (store_id={}...)", + infra.vss_url, + &store_id[..16] + ); + + let node = + builder.build_with_vss_store_and_fixed_headers(infra.vss_url, store_id, HashMap::new())?; + + Ok(Arc::new(node)) +} + +fn resolve_socks_proxy(raw: &str) -> Result { + let host_port = raw + .strip_prefix("socks5://") + .or_else(|| raw.strip_prefix("socks5h://")) + .ok_or_else(|| { + MdkError::InvalidInput( + "SOCKS5 proxy url must start with socks5:// or socks5h://".into(), + ) + })?; + + host_port + .to_socket_addrs() + .ok() + .and_then(|mut addrs| addrs.next()) + .ok_or_else(|| MdkError::InvalidInput(format!("cannot resolve SOCKS5 proxy: {host_port}"))) +} + +pub fn derive_vss_identifier(mnemonic: &Mnemonic) -> String { + let phrase = mnemonic.to_string(); + sha256::Hash::hash(phrase.as_bytes()).to_string() +} diff --git a/src/mdk/types.rs b/src/mdk/types.rs index 620c5e7..01b509b 100644 --- a/src/mdk/types.rs +++ b/src/mdk/types.rs @@ -1,113 +1,112 @@ -use std::fmt; +use ldk_node::ChannelDetails; -use serde::{Deserialize, Serialize}; - -// --- Requests --- - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CreateCheckoutRequest { - pub node_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option, - #[serde(skip_serializing_if = "Option::is_none")] +/// Parameters for creating a checkout (invoice + platform registration). +pub struct CreateCheckoutParams { + pub amount_sat: Option, + pub description: InvoiceDescription, + pub expiry_seconds: Option, + pub product: Option, pub currency: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub products: Option>, - #[serde(skip_serializing_if = "Option::is_none")] pub success_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub customer: Option, + pub customer: Option, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct CheckoutCustomer { - #[serde(skip_serializing_if = "Option::is_none")] +pub enum InvoiceDescription { + Direct(String), + Hash([u8; 32]), +} + +pub struct Customer { pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub external_id: Option, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RegisterInvoiceRequest { - pub node_id: String, - pub scid: String, +pub struct CheckoutResult { pub checkout_id: String, pub invoice: String, pub payment_hash: String, - pub invoice_expires_at: String, + pub amount_sat: Option, + pub expires_at: Option, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PaymentReceivedRequest { - pub payments: Vec, +pub struct Balance { + pub lightning_sats: u64, + pub onchain_sats: u64, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PaymentEntry { - pub payment_hash: String, - pub amount_sats: u64, - pub sandbox: bool, +pub struct Channel { + pub state: ChannelState, + pub channel_id: String, + pub balance_sats: u64, + pub inbound_liquidity_sats: u64, + pub capacity_sats: u64, + pub funding_tx_id: Option, } -// --- Responses --- - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub struct Checkout { - pub id: String, - pub status: String, - pub invoice_amount_sats: Option, - pub invoice_scid: Option, +pub enum ChannelState { + Online, + Offline, + Opening, } -#[derive(Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -#[allow(dead_code)] -pub struct PaymentReceivedResponse { - pub ok: bool, -} +impl From<&ChannelDetails> for Channel { + fn from(ch: &ChannelDetails) -> Self { + let state = match (ch.is_channel_ready, ch.is_usable) { + (true, true) => ChannelState::Online, + (true, false) => ChannelState::Offline, + (false, _) => ChannelState::Opening, + }; -// --- Errors --- + Self { + state, + channel_id: ch.channel_id.to_string(), + balance_sats: ch.outbound_capacity_msat / 1000, + inbound_liquidity_sats: ch.inbound_capacity_msat / 1000, + capacity_sats: ch.channel_value_sats, + funding_tx_id: ch.funding_txo.map(|txo| txo.txid.to_string()), + } + } +} -#[derive(Debug)] -pub enum MdkApiError { - Network(reqwest::Error), - Api { - code: String, - message: String, - status: u16, - }, - Deserialize(String), +pub struct NodeInfo { + pub node_id: String, + pub network: String, + pub block_height: u32, + pub channels: Vec, } -impl fmt::Display for MdkApiError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MdkApiError::Network(e) => write!(f, "network error: {e}"), - MdkApiError::Api { - code, - message, - status, - } => { - write!(f, "API error {status} [{code}]: {message}") - } - MdkApiError::Deserialize(msg) => write!(f, "deserialize error: {msg}"), - } - } +#[derive(Debug, Clone)] +pub enum MdkEvent { + PaymentReceived { + payment_hash: String, + amount_sats: u64, + }, + PaymentSuccessful { + payment_id: String, + payment_hash: Option, + fee_paid_sats: Option, + }, + PaymentFailed { + payment_id: String, + reason: Option, + }, + ChannelPending { + channel_id: String, + counterparty_node_id: String, + }, + ChannelReady { + channel_id: String, + counterparty_node_id: String, + }, + PaymentForwarded { + fee_earned_sats: Option, + }, } -impl From for MdkApiError { - fn from(e: reqwest::Error) -> Self { - MdkApiError::Network(e) - } +pub struct PaymentResult { + pub payment_id: String, + pub payment_hash: Option, + pub fee_paid_sats: Option, }