diff --git a/bindings/lni_nodejs/src/lib.rs b/bindings/lni_nodejs/src/lib.rs index 245e3eb..5e1e99f 100644 --- a/bindings/lni_nodejs/src/lib.rs +++ b/bindings/lni_nodejs/src/lib.rs @@ -7,6 +7,7 @@ pub use lni::ApiError; pub use lni::types::*; pub use lni::utils::*; pub use lni::types::{Transaction, InvoiceType, ListTransactionsParams, PayInvoiceResponse}; +pub use lni::lnbits::lib::{LnBitsConfig}; mod phoenixd; pub use phoenixd::PhoenixdNode; @@ -29,6 +30,9 @@ pub use strike::StrikeNode; mod speed; pub use speed::SpeedNode; +mod lnbits; +pub use lnbits::LnBitsNode; + use std::time::Duration; // Make an HTTP request to get IP address and simulate latency with optional SOCKS5 proxy diff --git a/bindings/lni_nodejs/src/lnbits.rs b/bindings/lni_nodejs/src/lnbits.rs new file mode 100644 index 0000000..e27e630 --- /dev/null +++ b/bindings/lni_nodejs/src/lnbits.rs @@ -0,0 +1,134 @@ +use lni::{ + lnbits::lib::LnBitsConfig, CreateInvoiceParams, LookupInvoiceParams, PayInvoiceParams, +}; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +#[napi] +pub struct LnBitsNode { + inner: LnBitsConfig, +} + +#[napi] +impl LnBitsNode { + #[napi(constructor)] + pub fn new(config: LnBitsConfig) -> Self { + Self { inner: config } + } + + #[napi] + pub fn get_base_url(&self) -> String { + self.inner.base_url.as_ref().unwrap_or(&"https://demo.lnbits.com".to_string()).clone() + } + + #[napi] + pub fn get_api_key(&self) -> String { + self.inner.api_key.clone() + } + + #[napi] + pub fn get_config(&self) -> LnBitsConfig { + LnBitsConfig { + base_url: self.inner.base_url.clone(), + api_key: self.inner.api_key.clone(), + socks5_proxy: self.inner.socks5_proxy.clone(), + accept_invalid_certs: self.inner.accept_invalid_certs, + http_timeout: self.inner.http_timeout, + } + } + + #[napi] + pub async fn get_info(&self) -> Result { + let info = lni::lnbits::api::get_info(&self.inner) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(info) + } + + #[napi] + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + let txn = lni::lnbits::api::create_invoice(&self.inner, params) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(txn) + } + + #[napi] + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + let response = lni::lnbits::api::pay_invoice(&self.inner, params) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(response) + } + + #[napi] + pub async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + let txn = lni::lnbits::api::lookup_invoice( + &self.inner, + params.payment_hash, + None, + None, + params.search, + ) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(txn) + } + + #[napi] + pub async fn list_transactions(&self, params: lni::types::ListTransactionsParams) -> Result> { + let txns = lni::lnbits::api::list_transactions(&self.inner, params.from, params.limit, params.search) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(txns) + } + + #[napi] + pub async fn decode(&self, invoice_str: String) -> Result { + let decoded = lni::lnbits::api::decode(&self.inner, invoice_str) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(decoded) + } + + // These BOLT12 functions return not implemented errors + #[napi] + pub async fn get_offer(&self, search: Option) -> Result { + let offer = lni::lnbits::api::get_offer(&self.inner, search) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(offer) + } + + #[napi] + pub async fn list_offers(&self, search: Option) -> Result> { + let offers = lni::lnbits::api::list_offers(&self.inner, search) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(offers) + } + + #[napi] + pub async fn pay_offer( + &self, + offer: String, + amount_msats: i64, + payer_note: Option, + ) -> Result { + let response = lni::lnbits::api::pay_offer(&self.inner, offer, amount_msats, payer_note) + .await + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(response) + } + + #[napi] + pub fn on_invoice_events( + &self, + params: lni::types::OnInvoiceEventParams, + callback: napi::JsFunction, + ) -> Result<()> { + // For simplicity, we'll just return an error indicating async callbacks are not yet implemented + // This would need more complex implementation similar to other providers + Err(napi::Error::from_reason("Invoice event callbacks not yet implemented for LNBits Node.js bindings".to_string())) + } +} \ No newline at end of file diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 4494857..52d4dda 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -72,6 +72,13 @@ pub mod speed { pub use lib::{SpeedConfig, SpeedNode}; } +pub mod lnbits { + pub mod api; + pub mod lib; + pub mod types; + pub use lib::{LnBitsConfig, LnBitsNode}; +} + pub mod types; pub use types::*; diff --git a/crates/lni/lnbits/api.rs b/crates/lni/lnbits/api.rs new file mode 100644 index 0000000..0bfd26e --- /dev/null +++ b/crates/lni/lnbits/api.rs @@ -0,0 +1,420 @@ +use std::str::FromStr; +use std::time::Duration; + +use lightning_invoice::Bolt11Invoice; + +use super::types::*; +use super::LnBitsConfig; +use crate::types::NodeInfo; +use crate::{ + ApiError, CreateInvoiceParams, InvoiceType, OnInvoiceEventCallback, OnInvoiceEventParams, + PayCode, PayInvoiceParams, PayInvoiceResponse, Transaction, +}; +use reqwest::header; + +// Docs: https://github.com/lnbits/lnbits/blob/main/docs/guide/api.md +// API: https://demo.lnbits.com/docs#/Payments + +fn client(config: &LnBitsConfig) -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + + // LNBits uses X-Api-Key header for authentication + match header::HeaderValue::from_str(&config.api_key) { + Ok(api_key_header) => headers.insert("X-Api-Key", api_key_header), + Err(_) => { + eprintln!("Failed to create API key header"); + return reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + } + }; + + headers.insert( + "Content-Type", + header::HeaderValue::from_static("application/json"), + ); + + // Create HTTP client with optional SOCKS5 proxy following existing patterns + if let Some(proxy_url) = config.socks5_proxy.clone() { + if !proxy_url.is_empty() { + // Accept invalid certificates when using SOCKS5 proxy + let client_builder = reqwest::Client::builder() + .default_headers(headers.clone()) + .danger_accept_invalid_certs(config.accept_invalid_certs.unwrap_or(true)); + + match reqwest::Proxy::all(&proxy_url) { + Ok(proxy) => { + let mut builder = client_builder.proxy(proxy); + if let Some(timeout) = config.http_timeout { + builder = builder.timeout(Duration::from_secs(timeout as u64)); + } + match builder.build() { + Ok(client) => return client, + Err(_) => {} // Fall through to default client + } + } + Err(_) => {} // Fall through to default client + } + } + } + + // Default client without proxy + let mut builder = reqwest::Client::builder().default_headers(headers); + if let Some(timeout) = config.http_timeout { + builder = builder.timeout(Duration::from_secs(timeout as u64)); + } + if config.accept_invalid_certs.unwrap_or(false) { + builder = builder.danger_accept_invalid_certs(true); + } + + builder.build().unwrap_or_else(|_| reqwest::Client::new()) +} + +fn get_base_url(config: &LnBitsConfig) -> String { + config.base_url.as_ref() + .unwrap_or(&"https://demo.lnbits.com".to_string()) + .clone() +} + +pub async fn get_info(config: &LnBitsConfig) -> Result { + let client = client(config); + + // Try to get wallet details first + let wallet_url = format!("{}/api/v1/wallet", get_base_url(config)); + let response = client + .get(&wallet_url) + .send() + .await + .map_err(|e| ApiError::Http { + reason: e.to_string(), + })?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("HTTP {} - {}", status, error_text), + }); + } + + let wallet: WalletDetails = response.json().await.map_err(|e| ApiError::Json { + reason: e.to_string(), + })?; + + Ok(NodeInfo { + alias: wallet.name.clone(), + color: "".to_string(), + pubkey: wallet.id.clone(), + network: "".to_string(), + block_height: 0, + block_hash: "".to_string(), + send_balance_msat: wallet.balance_msat, + receive_balance_msat: wallet.balance_msat, + fee_credit_balance_msat: 0, + unsettled_send_balance_msat: 0, + unsettled_receive_balance_msat: 0, + pending_open_send_balance: 0, + pending_open_receive_balance: 0, + }) +} + +pub async fn create_invoice( + config: &LnBitsConfig, + invoice_params: CreateInvoiceParams, +) -> Result { + match invoice_params.invoice_type { + InvoiceType::Bolt11 => { + let client = client(config); + + let amount_sats = invoice_params.amount_msats.unwrap_or(0) / 1000; + + let create_request = CreateInvoiceRequest { + out: false, // false for incoming invoices + amount: amount_sats, + memo: invoice_params.description.clone(), + unit: "sat".to_string(), + expiry: invoice_params.expiry, + webhook: None, + internal: Some(false), + }; + + let req_url = format!("{}/api/v1/payments", get_base_url(config)); + let response = client + .post(&req_url) + .json(&create_request) + .send() + .await + .map_err(|e| ApiError::Http { + reason: e.to_string(), + })?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("HTTP {} - {}", status, error_text), + }); + } + + let invoice_response: CreateInvoiceResponse = response.json().await.map_err(|e| ApiError::Json { + reason: e.to_string(), + })?; + + // Parse the bolt11 invoice to get expiry information + let expires_at = match Bolt11Invoice::from_str(&invoice_response.payment_request) { + Ok(invoice) => { + let created_at = invoice.duration_since_epoch().as_secs() as i64; + let expiry_duration = invoice.expiry_time().as_secs(); + created_at + expiry_duration as i64 + } + Err(_) => { + // Fallback calculation + chrono::Utc::now().timestamp() + invoice_params.expiry.unwrap_or(3600) + } + }; + + Ok(Transaction { + type_: "incoming".to_string(), + invoice: invoice_response.payment_request, + preimage: "".to_string(), + payment_hash: invoice_response.payment_hash, + amount_msats: invoice_params.amount_msats.unwrap_or(0), + fees_paid: 0, + created_at: chrono::Utc::now().timestamp(), + expires_at, + settled_at: 0, + description: invoice_params.description.unwrap_or_default(), + description_hash: invoice_params.description_hash.unwrap_or_default(), + payer_note: Some("".to_string()), + external_id: Some(invoice_response.checking_id), + }) + } + InvoiceType::Bolt12 => Err(ApiError::Json { + reason: "Bolt12 not implemented for LNBits".to_string(), + }), + } +} + +pub async fn pay_invoice( + config: &LnBitsConfig, + invoice_params: PayInvoiceParams, +) -> Result { + let client = client(config); + + let pay_request = PayInvoiceRequest { + out: true, // true for outgoing payments + bolt11: invoice_params.invoice.clone(), + }; + + let req_url = format!("{}/api/v1/payments", get_base_url(config)); + let response = client + .post(&req_url) + .json(&pay_request) + .send() + .await + .map_err(|e| ApiError::Http { + reason: e.to_string(), + })?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("HTTP {} - {}", status, error_text), + }); + } + + let pay_response: LnBitsPayInvoiceResponse = response.json().await.map_err(|e| ApiError::Json { + reason: e.to_string(), + })?; + + Ok(crate::PayInvoiceResponse { + payment_hash: pay_response.payment_hash, + preimage: "".to_string(), // Will be available later when checking payment status + fee_msats: 0, // LNBits doesn't return fees in the initial response + }) +} + +pub async fn lookup_invoice( + config: &LnBitsConfig, + payment_hash: Option, + _r_hash: Option, + _r_hash_str: Option, + _search: Option, +) -> Result { + if payment_hash.is_none() { + return Err(ApiError::Api { + reason: "payment_hash is required for LNBits lookup_invoice".to_string(), + }); + } + + let client = client(config); + let hash = payment_hash.unwrap(); + + let req_url = format!("{}/api/v1/payments/{}", get_base_url(config), hash); + let response = client + .get(&req_url) + .send() + .await + .map_err(|e| ApiError::Http { + reason: e.to_string(), + })?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("HTTP {} - {}", status, error_text), + }); + } + + let payment: Payment = response.json().await.map_err(|e| ApiError::Json { + reason: e.to_string(), + })?; + + Ok(Transaction { + type_: if payment.amount > 0 { "incoming".to_string() } else { "outgoing".to_string() }, + invoice: payment.payment_request, + preimage: payment.preimage.unwrap_or_default(), + payment_hash: payment.payment_hash, + amount_msats: payment.amount * 1000, // Convert sats to msats + fees_paid: payment.fee.unwrap_or(0) * 1000, // Convert sats to msats + created_at: payment.time, + expires_at: payment.expiry.unwrap_or(payment.time + 3600), + settled_at: if payment.pending { 0 } else { payment.time }, + description: payment.memo.unwrap_or_default(), + description_hash: "".to_string(), + payer_note: Some("".to_string()), + external_id: Some(payment.checking_id), + }) +} + +pub async fn list_transactions( + config: &LnBitsConfig, + _from: i64, + _limit: i64, + _search: Option, +) -> Result, ApiError> { + let client = client(config); + + let req_url = format!("{}/api/v1/payments", get_base_url(config)); + let response = client + .get(&req_url) + .send() + .await + .map_err(|e| ApiError::Http { + reason: e.to_string(), + })?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(ApiError::Http { + reason: format!("HTTP {} - {}", status, error_text), + }); + } + + let payments: Vec = response.json().await.map_err(|e| ApiError::Json { + reason: e.to_string(), + })?; + + let mut transactions = Vec::new(); + for payment in payments { + transactions.push(Transaction { + type_: if payment.amount > 0 { "incoming".to_string() } else { "outgoing".to_string() }, + invoice: payment.payment_request, + preimage: payment.preimage.unwrap_or_default(), + payment_hash: payment.payment_hash, + amount_msats: payment.amount * 1000, // Convert sats to msats + fees_paid: payment.fee.unwrap_or(0) * 1000, // Convert sats to msats + created_at: payment.time, + expires_at: payment.expiry.unwrap_or(payment.time + 3600), + settled_at: if payment.pending { 0 } else { payment.time }, + description: payment.memo.unwrap_or_default(), + description_hash: "".to_string(), + payer_note: Some("".to_string()), + external_id: Some(payment.checking_id), + }); + } + + Ok(transactions) +} + +pub async fn decode(_config: &LnBitsConfig, str: String) -> Result { + // For now, just return the original string + // LNBits doesn't have a specific decode endpoint in the basic API + match Bolt11Invoice::from_str(&str) { + Ok(invoice) => Ok(format!("{:?}", invoice)), + Err(e) => Err(ApiError::Api { + reason: format!("Failed to decode invoice: {}", e), + }), + } +} + +pub async fn get_offer(_config: &LnBitsConfig, _search: Option) -> Result { + Err(ApiError::Api { + reason: "BOLT12 offers not implemented for LNBits".to_string(), + }) +} + +pub async fn list_offers( + _config: &LnBitsConfig, + _search: Option, +) -> Result, ApiError> { + Err(ApiError::Api { + reason: "BOLT12 offers not implemented for LNBits".to_string(), + }) +} + +pub async fn pay_offer( + _config: &LnBitsConfig, + _offer: String, + _amount_msats: i64, + _payer_note: Option, +) -> Result { + Err(ApiError::Api { + reason: "BOLT12 offers not implemented for LNBits".to_string(), + }) +} + +pub async fn on_invoice_events( + config: LnBitsConfig, + params: OnInvoiceEventParams, + callback: Box, +) { + let payment_hash = match params.payment_hash { + Some(hash) => hash, + None => { + callback.failure(None); + return; + } + }; + + let polling_delay = Duration::from_secs(params.polling_delay_sec as u64); + let max_duration = Duration::from_secs(params.max_polling_sec as u64); + let start_time = std::time::Instant::now(); + + loop { + if start_time.elapsed() > max_duration { + callback.failure(None); + break; + } + + match lookup_invoice(&config, Some(payment_hash.clone()), None, None, None).await { + Ok(transaction) => { + if transaction.settled_at > 0 { + callback.success(Some(transaction)); + break; + } else { + callback.pending(Some(transaction)); + } + } + Err(_) => { + callback.pending(None); + } + } + + tokio::time::sleep(polling_delay).await; + } +} \ No newline at end of file diff --git a/crates/lni/lnbits/lib.rs b/crates/lni/lnbits/lib.rs new file mode 100644 index 0000000..74e316d --- /dev/null +++ b/crates/lni/lnbits/lib.rs @@ -0,0 +1,290 @@ +#[cfg(feature = "napi_rs")] +use napi_derive::napi; + +use crate::types::NodeInfo; +use crate::{ + ApiError, CreateInvoiceParams, LightningNode, ListTransactionsParams, LookupInvoiceParams, + PayCode, PayInvoiceParams, PayInvoiceResponse, Transaction, +}; + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone)] +pub struct LnBitsConfig { + #[cfg_attr(feature = "uniffi", uniffi(default = Some("https://demo.lnbits.com")))] + pub base_url: Option, + pub api_key: String, + #[cfg_attr(feature = "uniffi", uniffi(default = Some("")))] + pub socks5_proxy: Option, // Some("socks5h://127.0.0.1:9150") or Some("".to_string()) + #[cfg_attr(feature = "uniffi", uniffi(default = Some(true)))] + pub accept_invalid_certs: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = Some(120)))] + pub http_timeout: Option, +} + +impl Default for LnBitsConfig { + fn default() -> Self { + Self { + base_url: Some("https://demo.lnbits.com".to_string()), + api_key: "".to_string(), + socks5_proxy: Some("".to_string()), + accept_invalid_certs: Some(true), + http_timeout: Some(60), + } + } +} + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +#[derive(Debug, Clone)] +pub struct LnBitsNode { + pub config: LnBitsConfig, +} + +// Constructor is inherent, not part of the trait +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl LnBitsNode { + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub fn new(config: LnBitsConfig) -> Self { + Self { config } + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +#[async_trait::async_trait] +impl LightningNode for LnBitsNode { + async fn get_info(&self) -> Result { + crate::lnbits::api::get_info(&self.config).await + } + + async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + crate::lnbits::api::create_invoice(&self.config, params).await + } + + async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + crate::lnbits::api::pay_invoice(&self.config, params).await + } + + async fn get_offer(&self, search: Option) -> Result { + crate::lnbits::api::get_offer(&self.config, search).await + } + + async fn list_offers(&self, search: Option) -> Result, ApiError> { + crate::lnbits::api::list_offers(&self.config, search).await + } + + async fn pay_offer( + &self, + offer: String, + amount_msats: i64, + payer_note: Option, + ) -> Result { + crate::lnbits::api::pay_offer(&self.config, offer, amount_msats, payer_note).await + } + + async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + crate::lnbits::api::lookup_invoice( + &self.config, + params.payment_hash, + None, + None, + params.search, + ).await + } + + async fn list_transactions( + &self, + params: ListTransactionsParams, + ) -> Result, ApiError> { + crate::lnbits::api::list_transactions(&self.config, params.from, params.limit, params.search).await + } + + async fn decode(&self, str: String) -> Result { + crate::lnbits::api::decode(&self.config, str).await + } + + async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: Box, + ) { + crate::lnbits::api::on_invoice_events(self.config.clone(), params, callback).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::InvoiceType; + use dotenv::dotenv; + use lazy_static::lazy_static; + use std::env; + use std::sync::{Arc, Mutex}; + + lazy_static! { + static ref BASE_URL: String = { + dotenv().ok(); + env::var("LNBITS_BASE_URL") + .unwrap_or_else(|_| "https://demo.lnbits.com".to_string()) + }; + static ref API_KEY: String = { + dotenv().ok(); + env::var("LNBITS_API_KEY").expect("LNBITS_API_KEY must be set") + }; + static ref TEST_PAYMENT_HASH: String = { + dotenv().ok(); + env::var("LNBITS_TEST_PAYMENT_HASH").expect("LNBITS_TEST_PAYMENT_HASH must be set") + }; + static ref TEST_PAYMENT_REQUEST: String = { + dotenv().ok(); + env::var("LNBITS_TEST_PAYMENT_REQUEST").expect("LNBITS_TEST_PAYMENT_REQUEST must be set") + }; + static ref NODE: LnBitsNode = { + LnBitsNode::new(LnBitsConfig { + base_url: Some(BASE_URL.clone()), + api_key: API_KEY.clone(), + http_timeout: Some(120), + ..Default::default() + }) + }; + } + + #[tokio::test] + async fn test_get_info() { + match NODE.get_info().await { + Ok(info) => { + println!("info: {:?}", info); + } + Err(e) => { + println!("Failed to get info (expected if no API key): {:?}", e); + // Don't panic as this requires valid API key + } + } + } + + #[tokio::test] + async fn test_create_invoice() { + let amount_msats = 21000; // 21 sats + let description = "Test LNBits invoice".to_string(); + let expiry = 3600; + + match NODE.create_invoice(CreateInvoiceParams { + invoice_type: InvoiceType::Bolt11, + amount_msats: Some(amount_msats), + description: Some(description.clone()), + expiry: Some(expiry), + ..Default::default() + }).await { + Ok(txn) => { + println!("LNBits create_invoice: {:?}", txn); + assert!( + !txn.invoice.is_empty(), + "LNBits create_invoice Invoice should not be empty" + ); + } + Err(e) => { + println!( + "LNBits create_invoice failed (expected if no API key): {:?}", + e + ); + // Don't panic as this requires valid API key + } + } + } + + #[tokio::test] + async fn test_lookup_invoice() { + match NODE.lookup_invoice(LookupInvoiceParams { + payment_hash: Some(TEST_PAYMENT_HASH.to_string()), + ..Default::default() + }).await { + Ok(txn) => { + println!("LNBits lookup invoice: {:?}", txn); + assert!( + txn.amount_msats >= 0, + "Invoice should contain a valid amount" + ); + } + Err(e) => { + if e.to_string().contains("not found") { + assert!(true, "Invoice not found as expected"); + } else { + println!( + "LNBits lookup invoice failed (expected if no API key): {:?}", + e + ); + } + } + } + } + + #[tokio::test] + async fn test_list_transactions() { + let params = ListTransactionsParams { + from: 0, + limit: 100, + payment_hash: None, + search: None, + }; + + match NODE.list_transactions(params).await { + Ok(txns) => { + println!("LNBits transactions: {:?}", txns); + assert!(txns.len() >= 0, "Should contain at least zero transactions"); + } + Err(e) => { + println!( + "LNBits list transactions failed (expected if no API key): {:?}", + e + ); + // Don't panic as this requires valid API key + } + } + } + + #[tokio::test] + async fn test_on_invoice_events() { + struct OnInvoiceEventCallback { + events: Arc>>, + } + + impl crate::types::OnInvoiceEventCallback for OnInvoiceEventCallback { + fn success(&self, transaction: Option) { + let mut events = self.events.lock().unwrap(); + events.push(format!("{} - {:?}", "success", transaction)); + } + fn pending(&self, transaction: Option) { + let mut events = self.events.lock().unwrap(); + events.push(format!("{} - {:?}", "pending", transaction)); + } + fn failure(&self, transaction: Option) { + let mut events = self.events.lock().unwrap(); + events.push(format!("{} - {:?}", "failure", transaction)); + } + } + + let events = Arc::new(Mutex::new(Vec::new())); + let callback = OnInvoiceEventCallback { + events: events.clone(), + }; + + let params = crate::types::OnInvoiceEventParams { + payment_hash: Some(TEST_PAYMENT_HASH.to_string()), + polling_delay_sec: 2, + max_polling_sec: 5, + ..Default::default() + }; + + NODE.on_invoice_events(params, Box::new(callback)).await; + + // Check that some events were captured + let events_guard = events.lock().unwrap(); + println!("LNBits events captured: {:?}", *events_guard); + + // We expect at least one event (even if it's a failure due to invoice not found) + assert!( + !events_guard.is_empty(), + "Should capture at least one event" + ); + } +} \ No newline at end of file diff --git a/crates/lni/lnbits/types.rs b/crates/lni/lnbits/types.rs new file mode 100644 index 0000000..2ab545b --- /dev/null +++ b/crates/lni/lnbits/types.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +// LNBits API response types based on the Payments API + +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateInvoiceRequest { + pub out: bool, + pub amount: i64, + pub memo: Option, + pub unit: String, + pub expiry: Option, + pub webhook: Option, + pub internal: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateInvoiceResponse { + pub payment_hash: String, + pub payment_request: String, + pub checking_id: String, + pub lnurl_response: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PaymentStatus { + pub paid: bool, + pub preimage: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Payment { + pub payment_hash: String, + pub payment_request: String, + pub checking_id: String, + pub amount: i64, + pub fee: Option, + pub memo: Option, + pub time: i64, + pub bolt11: String, + pub preimage: Option, + pub pending: bool, + pub expiry: Option, + pub extra: Option, + pub wallet_id: String, + pub webhook: Option, + pub webhook_status: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PayInvoiceRequest { + pub out: bool, + pub bolt11: String, +} + +#[derive(Debug, Deserialize)] +pub struct LnBitsPayInvoiceResponse { + pub payment_hash: String, + pub checking_id: String, +} + +#[derive(Debug, Deserialize)] +pub struct WalletDetails { + pub id: String, + pub name: String, + pub user: String, + pub adminkey: String, + pub inkey: String, + pub balance_msat: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ApiInfo { + pub version: String, + pub node: Option, + pub network: Option, + pub lightning_implementation: Option, +} + +// Error response from LNBits API +#[derive(Debug, Deserialize)] +pub struct LnBitsError { + pub detail: String, +} + +impl std::fmt::Display for LnBitsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "LNBits API Error: {}", self.detail) + } +} + +impl std::error::Error for LnBitsError {} \ No newline at end of file