From a62c277d2c31b7e8e0c6d38e264317ad661d497b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:08:45 +0000 Subject: [PATCH 01/19] Initial plan From 330df9cfc900394ff53b0ddd1aac1497fbc1d603 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:23:21 +0000 Subject: [PATCH 02/19] Add Spark node integration using Breez Spark SDK Co-authored-by: ntheile <1273575+ntheile@users.noreply.github.com> --- crates/lni/Cargo.toml | 4 + crates/lni/lib.rs | 8 + crates/lni/spark/api.rs | 432 ++++++++++++++++++++++++++++++++++++++ crates/lni/spark/lib.rs | 300 ++++++++++++++++++++++++++ crates/lni/spark/types.rs | 44 ++++ 5 files changed, 788 insertions(+) create mode 100644 crates/lni/spark/api.rs create mode 100644 crates/lni/spark/lib.rs create mode 100644 crates/lni/spark/types.rs diff --git a/crates/lni/Cargo.toml b/crates/lni/Cargo.toml index e193943..c78d6dd 100644 --- a/crates/lni/Cargo.toml +++ b/crates/lni/Cargo.toml @@ -39,6 +39,9 @@ nwc = "0.43.0" nostr = "0.43.0" chrono = { version = "0.4", features = ["serde"] } +# Spark SDK (optional, requires feature flag) +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3", optional = true } + [dev-dependencies] async-attributes = "1.1.1" tokio = { version = "1", features = ["full"] } @@ -54,4 +57,5 @@ strip = "symbols" [features] napi_rs = [] uniffi = [] +spark = ["dep:breez-sdk-spark"] default = ["uniffi"] diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 4494857..713f9a0 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -72,6 +72,14 @@ pub mod speed { pub use lib::{SpeedConfig, SpeedNode}; } +#[cfg(feature = "spark")] +pub mod spark { + pub mod api; + pub mod lib; + pub mod types; + pub use lib::{SparkConfig, SparkNode}; +} + pub mod types; pub use types::*; diff --git a/crates/lni/spark/api.rs b/crates/lni/spark/api.rs new file mode 100644 index 0000000..7e272ce --- /dev/null +++ b/crates/lni/spark/api.rs @@ -0,0 +1,432 @@ +use std::sync::Arc; +use std::time::Duration; + +use breez_sdk_spark::{ + BreezSdk, GetInfoRequest, ListPaymentsRequest, PaymentDetails, PaymentStatus, PaymentType, + PrepareSendPaymentRequest, ReceivePaymentMethod, ReceivePaymentRequest, SendPaymentRequest, +}; + +use super::SparkConfig; +use crate::types::NodeInfo; +use crate::{ + ApiError, CreateInvoiceParams, InvoiceType, Offer, OnInvoiceEventCallback, + OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, Transaction, +}; + +/// Get node info from Spark SDK +pub async fn get_info(sdk: Arc) -> Result { + let info = sdk + .get_info(GetInfoRequest { + ensure_synced: Some(true), + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + Ok(NodeInfo { + alias: "Spark Node".to_string(), + color: "".to_string(), + pubkey: "".to_string(), + network: "mainnet".to_string(), + block_height: 0, + block_hash: "".to_string(), + send_balance_msat: (info.balance_sats as i64) * 1000, + receive_balance_msat: 0, // Spark doesn't have receive capacity limits + 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, + }) +} + +/// Create an invoice using Spark SDK +pub async fn create_invoice( + sdk: Arc, + invoice_params: CreateInvoiceParams, +) -> Result { + match invoice_params.invoice_type { + InvoiceType::Bolt11 => { + let response = sdk + .receive_payment(ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::Bolt11Invoice { + description: invoice_params.description.clone().unwrap_or_default(), + amount_sats: invoice_params.amount_msats.map(|m| (m / 1000) as u64), + }, + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + Ok(Transaction { + type_: "incoming".to_string(), + invoice: response.payment_request.clone(), + preimage: "".to_string(), + payment_hash: extract_payment_hash(&response.payment_request).unwrap_or_default(), + amount_msats: invoice_params.amount_msats.unwrap_or(0), + fees_paid: response.fee as i64, + created_at: now, + expires_at: now + invoice_params.expiry.unwrap_or(86400), + settled_at: 0, + description: invoice_params.description.unwrap_or_default(), + description_hash: invoice_params.description_hash.unwrap_or_default(), + payer_note: None, + external_id: None, + }) + } + InvoiceType::Bolt12 => Err(ApiError::Api { + reason: "Bolt12 not yet implemented for Spark".to_string(), + }), + } +} + +/// Pay an invoice using Spark SDK +pub async fn pay_invoice( + sdk: Arc, + invoice_params: PayInvoiceParams, +) -> Result { + // Prepare the payment first + let prepare_response = sdk + .prepare_send_payment(PrepareSendPaymentRequest { + payment_request: invoice_params.invoice.clone(), + amount: invoice_params.amount_msats.map(|m| (m / 1000) as u128), + token_identifier: None, + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + // Execute the payment + let response = sdk + .send_payment(SendPaymentRequest { + prepare_response, + options: None, + idempotency_key: None, + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + let (payment_hash, preimage) = match response.payment.details { + Some(PaymentDetails::Lightning { + payment_hash, + preimage, + .. + }) => (payment_hash, preimage.unwrap_or_default()), + Some(PaymentDetails::Spark { htlc_details, .. }) => { + if let Some(htlc) = htlc_details { + (htlc.payment_hash, htlc.preimage.unwrap_or_default()) + } else { + ("".to_string(), "".to_string()) + } + } + _ => ("".to_string(), "".to_string()), + }; + + Ok(PayInvoiceResponse { + payment_hash, + preimage, + fee_msats: (response.payment.fees as i64) * 1000, + }) +} + +/// Lookup an invoice by payment hash +pub async fn lookup_invoice( + sdk: Arc, + payment_hash: Option, + _from: Option, + _limit: Option, + _search: Option, +) -> Result { + let payments = sdk + .list_payments(ListPaymentsRequest { + limit: Some(100), + ..Default::default() + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + let target_hash = payment_hash.unwrap_or_default(); + + for payment in payments.payments { + let (invoice, p_hash, preimage, description) = match &payment.details { + Some(PaymentDetails::Lightning { + invoice, + payment_hash, + preimage, + description, + .. + }) => ( + invoice.clone(), + payment_hash.clone(), + preimage.clone().unwrap_or_default(), + description.clone().unwrap_or_default(), + ), + Some(PaymentDetails::Spark { + invoice_details, + htlc_details, + .. + }) => { + let inv = invoice_details + .as_ref() + .map(|d| d.invoice.clone()) + .unwrap_or_default(); + let desc = invoice_details + .as_ref() + .and_then(|d| d.description.clone()) + .unwrap_or_default(); + let (hash, preimg) = if let Some(htlc) = htlc_details { + (htlc.payment_hash.clone(), htlc.preimage.clone().unwrap_or_default()) + } else { + ("".to_string(), "".to_string()) + }; + (inv, hash, preimg, desc) + } + _ => continue, + }; + + if p_hash == target_hash || (target_hash.is_empty() && !p_hash.is_empty()) { + return Ok(Transaction { + type_: match payment.payment_type { + PaymentType::Send => "outgoing".to_string(), + PaymentType::Receive => "incoming".to_string(), + }, + invoice, + preimage, + payment_hash: p_hash, + amount_msats: (payment.amount as i64) * 1000, + fees_paid: (payment.fees as i64) * 1000, + created_at: payment.timestamp as i64, + expires_at: 0, + settled_at: if payment.status == PaymentStatus::Completed { + payment.timestamp as i64 + } else { + 0 + }, + description, + description_hash: "".to_string(), + payer_note: None, + external_id: Some(payment.id), + }); + } + } + + Err(ApiError::Api { + reason: format!("Invoice not found for payment hash: {}", target_hash), + }) +} + +/// List transactions from Spark SDK +pub async fn list_transactions( + sdk: Arc, + from: i64, + limit: i64, + _search: Option, +) -> Result, ApiError> { + let payments = sdk + .list_payments(ListPaymentsRequest { + offset: Some(from as u32), + limit: Some(limit as u32), + ..Default::default() + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + let mut transactions = Vec::new(); + + for payment in payments.payments { + let (invoice, payment_hash, preimage, description) = match &payment.details { + Some(PaymentDetails::Lightning { + invoice, + payment_hash, + preimage, + description, + .. + }) => ( + invoice.clone(), + payment_hash.clone(), + preimage.clone().unwrap_or_default(), + description.clone().unwrap_or_default(), + ), + Some(PaymentDetails::Spark { + invoice_details, + htlc_details, + .. + }) => { + let inv = invoice_details + .as_ref() + .map(|d| d.invoice.clone()) + .unwrap_or_default(); + let desc = invoice_details + .as_ref() + .and_then(|d| d.description.clone()) + .unwrap_or_default(); + let (hash, preimg) = if let Some(htlc) = htlc_details { + (htlc.payment_hash.clone(), htlc.preimage.clone().unwrap_or_default()) + } else { + ("".to_string(), "".to_string()) + }; + (inv, hash, preimg, desc) + } + Some(PaymentDetails::Deposit { tx_id }) => { + (tx_id.clone(), "".to_string(), "".to_string(), "Deposit".to_string()) + } + Some(PaymentDetails::Withdraw { tx_id }) => { + (tx_id.clone(), "".to_string(), "".to_string(), "Withdraw".to_string()) + } + Some(PaymentDetails::Token { tx_hash, .. }) => { + (tx_hash.clone(), "".to_string(), "".to_string(), "Token Transfer".to_string()) + } + None => continue, + }; + + transactions.push(Transaction { + type_: match payment.payment_type { + PaymentType::Send => "outgoing".to_string(), + PaymentType::Receive => "incoming".to_string(), + }, + invoice, + preimage, + payment_hash, + amount_msats: (payment.amount as i64) * 1000, + fees_paid: (payment.fees as i64) * 1000, + created_at: payment.timestamp as i64, + expires_at: 0, + settled_at: if payment.status == PaymentStatus::Completed { + payment.timestamp as i64 + } else { + 0 + }, + description, + description_hash: "".to_string(), + payer_note: None, + external_id: Some(payment.id), + }); + } + + Ok(transactions) +} + +/// Decode a payment request (not fully implemented for Spark yet) +pub fn decode(_config: &SparkConfig, str: String) -> Result { + // Spark SDK uses parse_input for decoding + Ok(str) +} + +/// Get offer (not implemented for Spark yet) +pub fn get_offer(_config: &SparkConfig, _search: Option) -> Result { + Err(ApiError::Api { + reason: "Bolt12 offers not yet implemented for Spark".to_string(), + }) +} + +/// List offers (not implemented for Spark yet) +pub fn list_offers(_config: &SparkConfig, _search: Option) -> Result, ApiError> { + Err(ApiError::Api { + reason: "Bolt12 offers not yet implemented for Spark".to_string(), + }) +} + +/// Pay offer (not implemented for Spark yet) +pub fn pay_offer( + _config: &SparkConfig, + _offer: String, + _amount_msats: i64, + _payer_note: Option, +) -> Result { + Err(ApiError::Api { + reason: "Bolt12 offers not yet implemented for Spark".to_string(), + }) +} + +/// Poll invoice events +pub async fn poll_invoice_events( + sdk: Arc, + params: OnInvoiceEventParams, + mut callback: F, +) where + F: FnMut(String, Option), +{ + let start_time = std::time::Instant::now(); + loop { + if start_time.elapsed() > Duration::from_secs(params.max_polling_sec as u64) { + callback("failure".to_string(), None); + break; + } + + let (status, transaction) = match lookup_invoice( + sdk.clone(), + params.payment_hash.clone(), + None, + None, + params.search.clone(), + ) + .await + { + Ok(transaction) => { + if transaction.settled_at > 0 { + ("settled".to_string(), Some(transaction)) + } else { + ("pending".to_string(), Some(transaction)) + } + } + Err(_) => ("error".to_string(), None), + }; + + match status.as_str() { + "settled" => { + callback("success".to_string(), transaction); + break; + } + "error" => { + callback("failure".to_string(), transaction); + } + _ => { + callback("pending".to_string(), transaction); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs( + params.polling_delay_sec as u64, + )) + .await; + } +} + +/// Handle invoice events with callback trait +pub async fn on_invoice_events( + sdk: Arc, + params: OnInvoiceEventParams, + callback: Box, +) { + poll_invoice_events(sdk, params, move |status, tx| match status.as_str() { + "success" => callback.success(tx), + "pending" => callback.pending(tx), + "failure" => callback.failure(tx), + _ => {} + }) + .await; +} + +/// Extract payment hash from a BOLT11 invoice +fn extract_payment_hash(invoice: &str) -> Option { + use lightning_invoice::Bolt11Invoice; + use std::str::FromStr; + + Bolt11Invoice::from_str(invoice) + .ok() + .map(|inv| format!("{:x}", inv.payment_hash())) +} diff --git a/crates/lni/spark/lib.rs b/crates/lni/spark/lib.rs new file mode 100644 index 0000000..9101fe5 --- /dev/null +++ b/crates/lni/spark/lib.rs @@ -0,0 +1,300 @@ +#[cfg(feature = "napi_rs")] +use napi_derive::napi; + +use std::sync::Arc; + +use breez_sdk_spark::{ + connect, default_config, BreezSdk, ConnectRequest, Network, Seed, +}; + +use crate::types::NodeInfo; +use crate::{ + ApiError, CreateInvoiceParams, CreateOfferParams, LightningNode, ListTransactionsParams, + LookupInvoiceParams, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, +}; + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone)] +pub struct SparkConfig { + /// 12 or 24 word mnemonic phrase + pub mnemonic: String, + /// Optional passphrase for the mnemonic + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub passphrase: Option, + /// Breez API key (required for mainnet) + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub api_key: Option, + /// Storage directory path for wallet data + pub storage_dir: String, + /// Network: "mainnet" or "regtest" + #[cfg_attr(feature = "uniffi", uniffi(default = Some("mainnet")))] + pub network: Option, +} + +impl Default for SparkConfig { + fn default() -> Self { + Self { + mnemonic: "".to_string(), + passphrase: None, + api_key: None, + storage_dir: "./spark_data".to_string(), + network: Some("mainnet".to_string()), + } + } +} + +impl SparkConfig { + fn get_network(&self) -> Network { + match self.network.as_deref() { + Some("regtest") => Network::Regtest, + _ => Network::Mainnet, + } + } +} + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct SparkNode { + pub config: SparkConfig, + sdk: Arc, +} + +// Constructor is inherent, not part of the trait +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +impl SparkNode { + /// Create a new SparkNode and connect to the Spark network + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub async fn new(config: SparkConfig) -> Result { + let network = config.get_network(); + let mut sdk_config = default_config(network); + sdk_config.api_key = config.api_key.clone(); + + let seed = Seed::Mnemonic { + mnemonic: config.mnemonic.clone(), + passphrase: config.passphrase.clone(), + }; + + let sdk = connect(ConnectRequest { + config: sdk_config, + seed, + storage_dir: config.storage_dir.clone(), + }) + .await + .map_err(|e| ApiError::Api { + reason: format!("Failed to connect to Spark: {}", e), + })?; + + Ok(Self { + config, + sdk: Arc::new(sdk), + }) + } + + /// Disconnect from the Spark network + pub async fn disconnect(&self) -> Result<(), ApiError> { + self.sdk.disconnect().await.map_err(|e| ApiError::Api { + reason: e.to_string(), + }) + } + + /// Get the Spark address for receiving payments + pub async fn get_spark_address(&self) -> Result { + use breez_sdk_spark::{ReceivePaymentMethod, ReceivePaymentRequest}; + + let response = self + .sdk + .receive_payment(ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::SparkAddress, + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + Ok(response.payment_request) + } + + /// Get a Bitcoin address for on-chain deposits + pub async fn get_deposit_address(&self) -> Result { + use breez_sdk_spark::{ReceivePaymentMethod, ReceivePaymentRequest}; + + let response = self + .sdk + .receive_payment(ReceivePaymentRequest { + payment_method: ReceivePaymentMethod::BitcoinAddress, + }) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + Ok(response.payment_request) + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +#[async_trait::async_trait] +impl LightningNode for SparkNode { + async fn get_info(&self) -> Result { + crate::spark::api::get_info(self.sdk.clone()).await + } + + async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + crate::spark::api::create_invoice(self.sdk.clone(), params).await + } + + async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + crate::spark::api::pay_invoice(self.sdk.clone(), params).await + } + + async fn create_offer(&self, _params: CreateOfferParams) -> Result { + Err(ApiError::Api { + reason: "create_offer not yet implemented for SparkNode".to_string(), + }) + } + + async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + crate::spark::api::lookup_invoice( + self.sdk.clone(), + params.payment_hash, + None, + None, + params.search, + ) + .await + } + + async fn list_transactions( + &self, + params: ListTransactionsParams, + ) -> Result, ApiError> { + crate::spark::api::list_transactions( + self.sdk.clone(), + params.from, + params.limit, + params.search, + ) + .await + } + + async fn decode(&self, str: String) -> Result { + crate::spark::api::decode(&self.config, str) + } + + async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: Box, + ) { + crate::spark::api::on_invoice_events(self.sdk.clone(), params, callback).await + } + + async fn get_offer(&self, search: Option) -> Result { + crate::spark::api::get_offer(&self.config, search) + } + + async fn list_offers(&self, search: Option) -> Result, ApiError> { + crate::spark::api::list_offers(&self.config, search) + } + + async fn pay_offer( + &self, + offer: String, + amount_msats: i64, + payer_note: Option, + ) -> Result { + crate::spark::api::pay_offer(&self.config, offer, amount_msats, payer_note) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dotenv::dotenv; + use lazy_static::lazy_static; + use std::env; + + lazy_static! { + static ref MNEMONIC: String = { + dotenv().ok(); + env::var("SPARK_MNEMONIC").unwrap_or_else(|_| { + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string() + }) + }; + static ref API_KEY: String = { + dotenv().ok(); + env::var("SPARK_API_KEY").unwrap_or_default() + }; + static ref STORAGE_DIR: String = { + dotenv().ok(); + env::var("SPARK_STORAGE_DIR").unwrap_or_else(|_| "/tmp/spark_test".to_string()) + }; + static ref TEST_PAYMENT_HASH: String = { + dotenv().ok(); + env::var("SPARK_TEST_PAYMENT_HASH").unwrap_or_default() + }; + } + + // Note: These tests require a valid Spark configuration to run + // They are skipped if the environment variables are not set + + #[tokio::test] + async fn test_spark_config_default() { + let config = SparkConfig::default(); + assert_eq!(config.network, Some("mainnet".to_string())); + assert!(config.mnemonic.is_empty()); + } + + #[tokio::test] + async fn test_spark_network_parsing() { + let config = SparkConfig { + network: Some("regtest".to_string()), + ..Default::default() + }; + assert!(matches!(config.get_network(), Network::Regtest)); + + let config = SparkConfig { + network: Some("mainnet".to_string()), + ..Default::default() + }; + assert!(matches!(config.get_network(), Network::Mainnet)); + } + + // Integration tests - require valid credentials + // Uncomment and set environment variables to run + + // #[tokio::test] + // async fn test_get_info() { + // if MNEMONIC.is_empty() || API_KEY.is_empty() { + // println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + // return; + // } + // + // let config = SparkConfig { + // mnemonic: MNEMONIC.clone(), + // api_key: Some(API_KEY.clone()), + // storage_dir: STORAGE_DIR.clone(), + // network: Some("mainnet".to_string()), + // passphrase: None, + // }; + // + // match SparkNode::new(config).await { + // Ok(node) => { + // match node.get_info().await { + // Ok(info) => { + // println!("Spark node info: {:?}", info); + // assert_eq!(info.alias, "Spark Node"); + // } + // Err(e) => { + // println!("Failed to get info: {:?}", e); + // } + // } + // let _ = node.disconnect().await; + // } + // Err(e) => { + // println!("Failed to connect: {:?}", e); + // } + // } + // } +} diff --git a/crates/lni/spark/types.rs b/crates/lni/spark/types.rs new file mode 100644 index 0000000..3f8cc17 --- /dev/null +++ b/crates/lni/spark/types.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// Spark SDK Network type +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum SparkNetwork { + Mainnet, + Regtest, +} + +impl Default for SparkNetwork { + fn default() -> Self { + Self::Mainnet + } +} + +/// Payment type from Spark SDK +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum SparkPaymentType { + Send, + Receive, +} + +/// Payment status from Spark SDK +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum SparkPaymentStatus { + Completed, + Pending, + Failed, +} + +/// Payment details from Spark SDK +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SparkPayment { + pub id: String, + pub payment_type: SparkPaymentType, + pub status: SparkPaymentStatus, + pub amount_sats: u64, + pub fees_sats: u64, + pub timestamp: u64, + pub invoice: Option, + pub payment_hash: Option, + pub preimage: Option, + pub description: Option, +} From e00e37ddbd0979e4cb125ded5a69de3e6840f4e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:27:47 +0000 Subject: [PATCH 03/19] Address code review feedback - improve type safety and validation Co-authored-by: ntheile <1273575+ntheile@users.noreply.github.com> --- crates/lni/spark/api.rs | 40 +++++++++++++++++++++----------- crates/lni/spark/lib.rs | 11 +++++---- crates/lni/spark/types.rs | 49 +++++---------------------------------- 3 files changed, 39 insertions(+), 61 deletions(-) diff --git a/crates/lni/spark/api.rs b/crates/lni/spark/api.rs index 7e272ce..1003d5e 100644 --- a/crates/lni/spark/api.rs +++ b/crates/lni/spark/api.rs @@ -6,7 +6,6 @@ use breez_sdk_spark::{ PrepareSendPaymentRequest, ReceivePaymentMethod, ReceivePaymentRequest, SendPaymentRequest, }; -use super::SparkConfig; use crate::types::NodeInfo; use crate::{ ApiError, CreateInvoiceParams, InvoiceType, Offer, OnInvoiceEventCallback, @@ -14,7 +13,7 @@ use crate::{ }; /// Get node info from Spark SDK -pub async fn get_info(sdk: Arc) -> Result { +pub async fn get_info(sdk: Arc, network: &str) -> Result { let info = sdk .get_info(GetInfoRequest { ensure_synced: Some(true), @@ -28,7 +27,7 @@ pub async fn get_info(sdk: Arc) -> Result { alias: "Spark Node".to_string(), color: "".to_string(), pubkey: "".to_string(), - network: "mainnet".to_string(), + network: network.to_string(), block_height: 0, block_hash: "".to_string(), send_balance_msat: (info.balance_sats as i64) * 1000, @@ -147,6 +146,16 @@ pub async fn lookup_invoice( _limit: Option, _search: Option, ) -> Result { + let target_hash = payment_hash.ok_or_else(|| ApiError::Api { + reason: "Payment hash is required for lookup".to_string(), + })?; + + if target_hash.is_empty() { + return Err(ApiError::Api { + reason: "Payment hash cannot be empty".to_string(), + }); + } + let payments = sdk .list_payments(ListPaymentsRequest { limit: Some(100), @@ -157,8 +166,6 @@ pub async fn lookup_invoice( reason: e.to_string(), })?; - let target_hash = payment_hash.unwrap_or_default(); - for payment in payments.payments { let (invoice, p_hash, preimage, description) = match &payment.details { Some(PaymentDetails::Lightning { @@ -196,7 +203,7 @@ pub async fn lookup_invoice( _ => continue, }; - if p_hash == target_hash || (target_hash.is_empty() && !p_hash.is_empty()) { + if p_hash == target_hash { return Ok(Transaction { type_: match payment.payment_type { PaymentType::Send => "outgoing".to_string(), @@ -320,21 +327,29 @@ pub async fn list_transactions( Ok(transactions) } -/// Decode a payment request (not fully implemented for Spark yet) -pub fn decode(_config: &SparkConfig, str: String) -> Result { - // Spark SDK uses parse_input for decoding - Ok(str) +/// Decode a payment request using the SDK's parse functionality +pub async fn decode(sdk: Arc, input: String) -> Result { + // Use the SDK's parse method to decode the input + match sdk.parse(&input).await { + Ok(parsed) => { + // Return a JSON representation of the parsed input + Ok(format!("{:?}", parsed)) + } + Err(e) => Err(ApiError::Api { + reason: format!("Failed to decode input: {}", e), + }), + } } /// Get offer (not implemented for Spark yet) -pub fn get_offer(_config: &SparkConfig, _search: Option) -> Result { +pub fn get_offer(_search: Option) -> Result { Err(ApiError::Api { reason: "Bolt12 offers not yet implemented for Spark".to_string(), }) } /// List offers (not implemented for Spark yet) -pub fn list_offers(_config: &SparkConfig, _search: Option) -> Result, ApiError> { +pub fn list_offers(_search: Option) -> Result, ApiError> { Err(ApiError::Api { reason: "Bolt12 offers not yet implemented for Spark".to_string(), }) @@ -342,7 +357,6 @@ pub fn list_offers(_config: &SparkConfig, _search: Option) -> Result, diff --git a/crates/lni/spark/lib.rs b/crates/lni/spark/lib.rs index 9101fe5..349c87f 100644 --- a/crates/lni/spark/lib.rs +++ b/crates/lni/spark/lib.rs @@ -137,7 +137,8 @@ impl SparkNode { #[async_trait::async_trait] impl LightningNode for SparkNode { async fn get_info(&self) -> Result { - crate::spark::api::get_info(self.sdk.clone()).await + let network = self.config.network.as_deref().unwrap_or("mainnet"); + crate::spark::api::get_info(self.sdk.clone(), network).await } async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { @@ -179,7 +180,7 @@ impl LightningNode for SparkNode { } async fn decode(&self, str: String) -> Result { - crate::spark::api::decode(&self.config, str) + crate::spark::api::decode(self.sdk.clone(), str).await } async fn on_invoice_events( @@ -191,11 +192,11 @@ impl LightningNode for SparkNode { } async fn get_offer(&self, search: Option) -> Result { - crate::spark::api::get_offer(&self.config, search) + crate::spark::api::get_offer(search) } async fn list_offers(&self, search: Option) -> Result, ApiError> { - crate::spark::api::list_offers(&self.config, search) + crate::spark::api::list_offers(search) } async fn pay_offer( @@ -204,7 +205,7 @@ impl LightningNode for SparkNode { amount_msats: i64, payer_note: Option, ) -> Result { - crate::spark::api::pay_offer(&self.config, offer, amount_msats, payer_note) + crate::spark::api::pay_offer(offer, amount_msats, payer_note) } } diff --git a/crates/lni/spark/types.rs b/crates/lni/spark/types.rs index 3f8cc17..402db18 100644 --- a/crates/lni/spark/types.rs +++ b/crates/lni/spark/types.rs @@ -1,44 +1,7 @@ -use serde::{Deserialize, Serialize}; +//! Types for Spark node integration. +//! +//! Note: Most types are provided directly by the breez-sdk-spark crate. +//! This module contains any additional types needed for the integration. -/// Spark SDK Network type -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum SparkNetwork { - Mainnet, - Regtest, -} - -impl Default for SparkNetwork { - fn default() -> Self { - Self::Mainnet - } -} - -/// Payment type from Spark SDK -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum SparkPaymentType { - Send, - Receive, -} - -/// Payment status from Spark SDK -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub enum SparkPaymentStatus { - Completed, - Pending, - Failed, -} - -/// Payment details from Spark SDK -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SparkPayment { - pub id: String, - pub payment_type: SparkPaymentType, - pub status: SparkPaymentStatus, - pub amount_sats: u64, - pub fees_sats: u64, - pub timestamp: u64, - pub invoice: Option, - pub payment_hash: Option, - pub preimage: Option, - pub description: Option, -} +// Currently, all types are provided by the breez-sdk-spark crate directly. +// This module is kept for future extensions if needed. From aab912fb55c8dd57bf0887ea57fb172988c398c4 Mon Sep 17 00:00:00 2001 From: nicktee Date: Sat, 3 Jan 2026 20:48:25 -0600 Subject: [PATCH 04/19] spark test fixes --- Cargo.toml | 1 + bindings/lni_nodejs/Cargo.toml | 4 +- crates/lni/Cargo.toml | 9 +- crates/lni/lib.rs | 1 - crates/lni/spark/lib.rs | 286 ++++++++++++++++++++++++++++----- 5 files changed, 251 insertions(+), 50 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e04f7d2..26544ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "crates/lni", "bindings/lni_nodejs", diff --git a/bindings/lni_nodejs/Cargo.toml b/bindings/lni_nodejs/Cargo.toml index c37b9f4..be0f8c9 100644 --- a/bindings/lni_nodejs/Cargo.toml +++ b/bindings/lni_nodejs/Cargo.toml @@ -17,8 +17,8 @@ serde = { version = "1", features=["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "blocking", "socks"] } tokio = { version = "1", features = ["full"] } -napi = { version = "2.16.17", default-features = false, features = ["napi4", "tokio_rt", "async"] } -napi-derive = "2.16.13" +napi = { version = "2.16", default-features = false, features = ["napi4", "tokio_rt", "async"] } +napi-derive = "2.16" dotenv = "0.15.0" lazy_static = "1.4.0" diff --git a/crates/lni/Cargo.toml b/crates/lni/Cargo.toml index c78d6dd..4a2dbeb 100644 --- a/crates/lni/Cargo.toml +++ b/crates/lni/Cargo.toml @@ -20,8 +20,8 @@ thiserror = "1.0" serde = { version = "1", features = ["derive"] } serde_json = "1" uniffi = { version = "0.29.0", features = ["tokio", "cli"] } -napi = { version = "2.16.17", default-features = false, features = ["napi4"] } -napi-derive = "2.16.13" +napi = { version = "2.16", default-features = false, features = ["napi4"] } +napi-derive = "2.16" async-std = "1.10.0" tokio = { version = "1", features = ["full"] } dotenv = "0.15.0" @@ -38,9 +38,7 @@ uuid = { version = "1.0", features = ["v4"] } nwc = "0.43.0" nostr = "0.43.0" chrono = { version = "0.4", features = ["serde"] } - -# Spark SDK (optional, requires feature flag) -breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3", optional = true } +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3" } [dev-dependencies] async-attributes = "1.1.1" @@ -57,5 +55,4 @@ strip = "symbols" [features] napi_rs = [] uniffi = [] -spark = ["dep:breez-sdk-spark"] default = ["uniffi"] diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 713f9a0..0e7c677 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -72,7 +72,6 @@ pub mod speed { pub use lib::{SpeedConfig, SpeedNode}; } -#[cfg(feature = "spark")] pub mod spark { pub mod api; pub mod lib; diff --git a/crates/lni/spark/lib.rs b/crates/lni/spark/lib.rs index 349c87f..ebb397b 100644 --- a/crates/lni/spark/lib.rs +++ b/crates/lni/spark/lib.rs @@ -53,7 +53,8 @@ impl SparkConfig { } } -#[cfg_attr(feature = "napi_rs", napi(object))] +// Note: SparkNode cannot use napi(object) because BreezSdk has private fields +// #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] pub struct SparkNode { pub config: SparkConfig, @@ -212,16 +213,18 @@ impl LightningNode for SparkNode { #[cfg(test)] mod tests { use super::*; + use crate::{CreateInvoiceParams, InvoiceType, ListTransactionsParams, LookupInvoiceParams, PayInvoiceParams}; use dotenv::dotenv; use lazy_static::lazy_static; use std::env; + use std::sync::Once; + + static INIT: Once = Once::new(); lazy_static! { static ref MNEMONIC: String = { dotenv().ok(); - env::var("SPARK_MNEMONIC").unwrap_or_else(|_| { - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string() - }) + env::var("SPARK_MNEMONIC").unwrap_or_default() }; static ref API_KEY: String = { dotenv().ok(); @@ -235,10 +238,28 @@ mod tests { dotenv().ok(); env::var("SPARK_TEST_PAYMENT_HASH").unwrap_or_default() }; + static ref TEST_RECEIVER_OFFER: String = { + dotenv().ok(); + env::var("TEST_RECEIVER_OFFER").unwrap_or_default() + }; } - // Note: These tests require a valid Spark configuration to run - // They are skipped if the environment variables are not set + fn should_skip() -> bool { + MNEMONIC.is_empty() || API_KEY.is_empty() + } + + async fn get_node() -> Result { + let config = SparkConfig { + mnemonic: MNEMONIC.clone(), + api_key: Some(API_KEY.clone()), + storage_dir: STORAGE_DIR.clone(), + network: Some("mainnet".to_string()), + passphrase: None, + }; + SparkNode::new(config).await + } + + // Unit tests - no credentials required #[tokio::test] async fn test_spark_config_default() { @@ -263,39 +284,222 @@ mod tests { } // Integration tests - require valid credentials - // Uncomment and set environment variables to run - - // #[tokio::test] - // async fn test_get_info() { - // if MNEMONIC.is_empty() || API_KEY.is_empty() { - // println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); - // return; - // } - // - // let config = SparkConfig { - // mnemonic: MNEMONIC.clone(), - // api_key: Some(API_KEY.clone()), - // storage_dir: STORAGE_DIR.clone(), - // network: Some("mainnet".to_string()), - // passphrase: None, - // }; - // - // match SparkNode::new(config).await { - // Ok(node) => { - // match node.get_info().await { - // Ok(info) => { - // println!("Spark node info: {:?}", info); - // assert_eq!(info.alias, "Spark Node"); - // } - // Err(e) => { - // println!("Failed to get info: {:?}", e); - // } - // } - // let _ = node.disconnect().await; - // } - // Err(e) => { - // println!("Failed to connect: {:?}", e); - // } - // } - // } + // Set SPARK_MNEMONIC and SPARK_API_KEY environment variables to run + #[tokio::test] + async fn test_get_info() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + match node.get_info().await { + Ok(info) => { + println!("info: {:?}", info); + } + Err(e) => { + panic!("Failed to get info: {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_create_invoice() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + let params = CreateInvoiceParams { + invoice_type: InvoiceType::Bolt11, + amount_msats: Some(1000), + offer: None, + description: Some("Test invoice".to_string()), + description_hash: None, + expiry: Some(3600), + ..Default::default() + }; + + match node.create_invoice(params).await { + Ok(txn) => { + println!("txn: {:?}", txn); + assert!(!txn.invoice.is_empty(), "Invoice should not be empty"); + } + Err(e) => { + panic!("Failed to make invoice: {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_pay_invoice() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + // Note: This test requires a valid invoice to pay + // For now we'll just test that the function exists and handles errors + match node.pay_invoice(PayInvoiceParams { + invoice: "lnbc1***".to_string(), // Invalid invoice for testing + ..Default::default() + }).await { + Ok(txn) => { + println!("txn: {:?}", txn); + assert!(!txn.payment_hash.is_empty(), "Payment hash should not be empty"); + } + Err(e) => { + println!("Expected error for invalid invoice: {:?}", e); + // This is expected to fail with an invalid invoice + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_list_transactions() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + let params = ListTransactionsParams { + from: 0, + limit: 10, + payment_hash: None, + search: None, + }; + + match node.list_transactions(params).await { + Ok(txns) => { + println!("transactions: {:?}", txns); + assert!(true, "Successfully fetched transactions"); + } + Err(e) => { + panic!("Failed to list transactions: {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_lookup_invoice() { + if should_skip() || TEST_PAYMENT_HASH.is_empty() { + println!("Skipping test: credentials or SPARK_TEST_PAYMENT_HASH not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + match node.lookup_invoice(LookupInvoiceParams { + payment_hash: Some(TEST_PAYMENT_HASH.to_string()), + ..Default::default() + }).await { + Ok(txn) => { + println!("txn: {:?}", txn); + assert!(txn.amount_msats > 0, "Invoice should contain an amount"); + } + Err(e) => { + panic!("Failed to lookup invoice: {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_get_spark_address() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + match node.get_spark_address().await { + Ok(address) => { + println!("Spark address: {}", address); + assert!(!address.is_empty(), "Spark address should not be empty"); + } + Err(e) => { + panic!("Failed to get Spark address: {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_get_deposit_address() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + match node.get_deposit_address().await { + Ok(address) => { + println!("Bitcoin deposit address: {}", address); + assert!(!address.is_empty(), "Deposit address should not be empty"); + } + Err(e) => { + panic!("Failed to get deposit address: {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_decode() { + if should_skip() { + println!("Skipping test: SPARK_MNEMONIC or SPARK_API_KEY not set"); + return; + } + + let node = get_node().await.expect("Failed to connect"); + // Test decoding a BOLT11 invoice + let test_invoice = "lnbc1..."; // You can put a valid invoice here for testing + match node.decode(test_invoice.to_string()).await { + Ok(decoded) => { + println!("Decoded: {}", decoded); + } + Err(e) => { + println!("Decode error (may be expected for invalid input): {:?}", e); + } + } + let _ = node.disconnect().await; + } + + #[tokio::test] + async fn test_on_invoice_events() { + if should_skip() || TEST_PAYMENT_HASH.is_empty() { + println!("Skipping test: credentials or SPARK_TEST_PAYMENT_HASH not set"); + return; + } + + struct TestInvoiceEventCallback; + impl crate::types::OnInvoiceEventCallback for TestInvoiceEventCallback { + fn success(&self, transaction: Option) { + println!("success: {:?}", transaction); + } + fn pending(&self, transaction: Option) { + println!("pending: {:?}", transaction); + } + fn failure(&self, transaction: Option) { + println!("failure: {:?}", transaction); + } + } + + let node = get_node().await.expect("Failed to connect"); + let params = crate::types::OnInvoiceEventParams { + search: Some(TEST_PAYMENT_HASH.to_string()), + polling_delay_sec: 2, + max_polling_sec: 6, + ..Default::default() + }; + let callback = TestInvoiceEventCallback; + node.on_invoice_events(params, Box::new(callback)).await; + let _ = node.disconnect().await; + } } From 42eef3984ed15058a8c0bc956b05caf12a0207ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 00:35:46 +0000 Subject: [PATCH 05/19] Update Spark implementation to support polymorphism pattern from PR #20 Co-authored-by: ntheile <1273575+ntheile@users.noreply.github.com> --- crates/lni/Cargo.toml | 6 +- crates/lni/blink/api.rs | 4 +- crates/lni/blink/lib.rs | 40 +++++---- crates/lni/cln/api.rs | 20 ++--- crates/lni/cln/lib.rs | 48 +++++----- crates/lni/lib.rs | 175 ++++++++++++++++++++++++++++++++++++- crates/lni/lnd/api.rs | 16 ++-- crates/lni/lnd/lib.rs | 45 ++++++---- crates/lni/nwc/api.rs | 2 +- crates/lni/nwc/lib.rs | 41 +++++---- crates/lni/phoenixd/api.rs | 7 +- crates/lni/phoenixd/lib.rs | 56 ++++++++---- crates/lni/spark/api.rs | 4 +- crates/lni/spark/lib.rs | 40 +++++---- crates/lni/speed/api.rs | 4 +- crates/lni/speed/lib.rs | 51 ++++++----- crates/lni/strike/api.rs | 6 +- crates/lni/strike/lib.rs | 53 ++++++----- crates/lni/types.rs | 60 +++++++++---- 19 files changed, 469 insertions(+), 209 deletions(-) diff --git a/crates/lni/Cargo.toml b/crates/lni/Cargo.toml index 4a2dbeb..2d8225f 100644 --- a/crates/lni/Cargo.toml +++ b/crates/lni/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [lib] name = "lni" path = "lib.rs" -crate-type = ["staticlib", "lib"] # *NOTE - this is needed for uniffi to generate the correct bindings in react native, comment this out when building for napi_rs +crate-type = ["staticlib", "cdylib", "lib"] # staticlib for iOS, cdylib for Android/uniffi bindgen, lib for Rust consumers [dependencies] reqwest = { version = "0.12", default-features = false, features = [ @@ -38,7 +38,8 @@ uuid = { version = "1.0", features = ["v4"] } nwc = "0.43.0" nostr = "0.43.0" chrono = { version = "0.4", features = ["serde"] } -breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3" } +once_cell = "1.19" +breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3", optional = true } [dev-dependencies] async-attributes = "1.1.1" @@ -55,4 +56,5 @@ strip = "symbols" [features] napi_rs = [] uniffi = [] +spark = ["breez-sdk-spark"] default = ["uniffi"] diff --git a/crates/lni/blink/api.rs b/crates/lni/blink/api.rs index d9b18da..e9d3073 100644 --- a/crates/lni/blink/api.rs +++ b/crates/lni/blink/api.rs @@ -205,7 +205,7 @@ pub async fn create_invoice( config: &BlinkConfig, invoice_params: CreateInvoiceParams, ) -> Result { - match invoice_params.invoice_type { + match invoice_params.get_invoice_type() { InvoiceType::Bolt11 => { let wallet_id = get_btc_wallet_id(config).await?; @@ -668,7 +668,7 @@ where pub async fn on_invoice_events( config: BlinkConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(&config, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), diff --git a/crates/lni/blink/lib.rs b/crates/lni/blink/lib.rs index f0fca53..91cf166 100644 --- a/crates/lni/blink/lib.rs +++ b/crates/lni/blink/lib.rs @@ -4,8 +4,10 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, - Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, LightningNode, + Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -50,34 +52,34 @@ impl BlinkNode { } } +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for BlinkNode { - async fn get_info(&self) -> Result { +impl BlinkNode { + pub async fn get_info(&self) -> Result { crate::blink::api::get_info(&self.config).await } - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { crate::blink::api::create_invoice(&self.config, params).await } - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { crate::blink::api::pay_invoice(&self.config, params).await } - async fn create_offer(&self, _params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not implemented for BlinkNode".to_string() }) } - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::blink::api::get_offer(&self.config, search).await } - async fn list_offers(&self, search: Option) -> Result, ApiError> { + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { crate::blink::api::list_offers(&self.config, search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -86,7 +88,7 @@ impl LightningNode for BlinkNode { crate::blink::api::pay_offer(&self.config, offer, amount_msats, payer_note).await } - async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + pub async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { crate::blink::api::lookup_invoice( &self.config, params.payment_hash, @@ -96,26 +98,30 @@ impl LightningNode for BlinkNode { ).await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { crate::blink::api::list_transactions(&self.config, params.from, params.limit, params.search).await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::blink::api::decode(&self.config, str).await } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { crate::blink::api::on_invoice_events(self.config.clone(), params, callback).await } } +// Trait implementation for Rust consumers - uses the impl_lightning_node macro +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(BlinkNode); + #[cfg(test)] mod tests { use super::*; @@ -173,7 +179,7 @@ mod tests { let expiry = 3600; match NODE.create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), description: Some(description.clone()), expiry: Some(expiry), @@ -309,7 +315,7 @@ mod tests { ..Default::default() }; - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; // Check that some events were captured let events_guard = events.lock().unwrap(); diff --git a/crates/lni/cln/api.rs b/crates/lni/cln/api.rs index e99d95b..2b51a6a 100644 --- a/crates/lni/cln/api.rs +++ b/crates/lni/cln/api.rs @@ -802,18 +802,16 @@ pub async fn poll_invoice_events( } } -pub fn on_invoice_events( +pub async fn on_invoice_events( config: ClnConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { - tokio::task::spawn(async move { - poll_invoice_events(config, params, move |status, tx| match status.as_str() { - "success" => callback.success(tx), - "pending" => callback.pending(tx), - "failure" => callback.failure(tx), - _ => {} - }) - .await; - }); + poll_invoice_events(config, params, move |status, tx| match status.as_str() { + "success" => callback.success(tx), + "pending" => callback.pending(tx), + "failure" => callback.failure(tx), + _ => {} + }) + .await; } diff --git a/crates/lni/cln/lib.rs b/crates/lni/cln/lib.rs index c02fbb9..04d1b54 100644 --- a/crates/lni/cln/lib.rs +++ b/crates/lni/cln/lib.rs @@ -4,8 +4,10 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, Offer, - PayInvoiceParams, PayInvoiceResponse, Transaction, LightningNode, + PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -48,20 +50,20 @@ impl ClnNode { } } +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for ClnNode { - async fn get_info(&self) -> Result { +impl ClnNode { + pub async fn get_info(&self) -> Result { crate::cln::api::get_info(self.config.clone()).await } - async fn create_invoice( + pub async fn create_invoice( &self, params: CreateInvoiceParams, ) -> Result { crate::cln::api::create_invoice( self.config.clone(), - params.invoice_type, + params.get_invoice_type(), params.amount_msats, params.offer.clone(), params.description, @@ -71,26 +73,26 @@ impl LightningNode for ClnNode { .await } - async fn pay_invoice( + pub async fn pay_invoice( &self, params: PayInvoiceParams, ) -> Result { crate::cln::api::pay_invoice(self.config.clone(), params).await } - async fn create_offer(&self, params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, params: CreateOfferParams) -> Result { crate::cln::api::create_offer(self.config.clone(), params).await } - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::cln::api::get_offer(self.config.clone(), search).await } - async fn list_offers(&self, search: Option) -> Result, ApiError> { + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { crate::cln::api::list_offers(self.config.clone(), search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -99,7 +101,7 @@ impl LightningNode for ClnNode { crate::cln::api::pay_offer(self.config.clone(), offer, amount_msats, payer_note).await } - async fn lookup_invoice( + pub async fn lookup_invoice( &self, params: LookupInvoiceParams, ) -> Result { @@ -113,7 +115,7 @@ impl LightningNode for ClnNode { .await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { @@ -126,19 +128,23 @@ impl LightningNode for ClnNode { .await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::cln::api::decode(self.config.clone(), str).await } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { - crate::cln::api::on_invoice_events(self.config.clone(), params, callback) + crate::cln::api::on_invoice_events(self.config.clone(), params, callback).await } } +// Trait implementation for Rust consumers - uses the impl_lightning_node macro +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(ClnNode); + #[cfg(test)] mod tests { use crate::InvoiceType; @@ -207,7 +213,7 @@ mod tests { // BOLT11 match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), description: Some(description.clone()), description_hash: Some(description_hash.clone()), @@ -231,7 +237,7 @@ mod tests { // BOLT11 - Zero amount match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), expiry: Some(expiry), ..Default::default() }) @@ -252,7 +258,7 @@ mod tests { // BOLT12 match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt12, + invoice_type: Some(InvoiceType::Bolt12), amount_msats: Some(amount_msats), offer: Some(PHOENIX_MOBILE_OFFER.to_string()), description: Some(description.clone()), @@ -491,6 +497,6 @@ mod tests { ..Default::default() }; let callback = OnInvoiceEventCallback {}; - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; } } diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 0e7c677..2df67a5 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -4,8 +4,20 @@ use napi_derive::napi; use napi::bindgen_prelude::*; use std::time::Duration; +use once_cell::sync::Lazy; -#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +// Global Tokio runtime for async operations +// This is needed because UniFFI's async trait support requires a runtime that's always available +// Swift/Kotlin drive the outer future (UniFFI's bridging), while Tokio drives the actual async work +pub static TOKIO_RUNTIME: Lazy = Lazy::new(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("lni-tokio") + .build() + .expect("Failed to create Tokio runtime for LNI") +}); + +#[cfg_attr(feature = "uniffi", derive(uniffi::Error))] #[derive(Debug, thiserror::Error)] pub enum ApiError { #[error("HttpError: {reason}")] @@ -23,6 +35,109 @@ impl From for ApiError { } } +/// Macro to implement LightningNode trait by delegating to inherent methods. +/// This avoids code duplication between UniFFI exports and trait implementations. +/// The macro works for both UniFFI and non-UniFFI builds. +/// +/// For UniFFI builds, the async work is spawned onto the global TOKIO_RUNTIME +/// since Swift/Kotlin drive the outer future but Tokio needs to drive the actual async work. +#[macro_export] +macro_rules! impl_lightning_node { + ($node_type:ty) => { + #[async_trait::async_trait] + impl crate::LightningNode for $node_type { + async fn get_info(&self) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::get_info(&this).await + }).await.unwrap() + } + + async fn create_invoice(&self, params: crate::CreateInvoiceParams) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::create_invoice(&this, params).await + }).await.unwrap() + } + + async fn pay_invoice(&self, params: crate::PayInvoiceParams) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::pay_invoice(&this, params).await + }).await.unwrap() + } + + async fn create_offer(&self, params: crate::CreateOfferParams) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::create_offer(&this, params).await + }).await.unwrap() + } + + async fn get_offer(&self, search: Option) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::get_offer(&this, search).await + }).await.unwrap() + } + + async fn list_offers(&self, search: Option) -> Result, crate::ApiError> { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::list_offers(&this, search).await + }).await.unwrap() + } + + async fn pay_offer( + &self, + offer: String, + amount_msats: i64, + payer_note: Option, + ) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::pay_offer(&this, offer, amount_msats, payer_note).await + }).await.unwrap() + } + + async fn lookup_invoice(&self, params: crate::LookupInvoiceParams) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::lookup_invoice(&this, params).await + }).await.unwrap() + } + + async fn list_transactions( + &self, + params: crate::ListTransactionsParams, + ) -> Result, crate::ApiError> { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::list_transactions(&this, params).await + }).await.unwrap() + } + + async fn decode(&self, str: String) -> Result { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::decode(&this, str).await + }).await.unwrap() + } + + async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: std::sync::Arc, + ) { + let this = self.clone(); + crate::TOKIO_RUNTIME.spawn(async move { + <$node_type>::on_invoice_events(&this, params, callback).await + }).await.unwrap() + } + } + }; +} + pub mod phoenixd { pub mod api; pub mod lib; @@ -72,6 +187,7 @@ pub mod speed { pub use lib::{SpeedConfig, SpeedNode}; } +#[cfg(feature = "spark")] pub mod spark { pub mod api; pub mod lib; @@ -89,7 +205,7 @@ pub mod database; pub use database::{Db, DbError, Payment}; // Make an HTTP request to get IP address and simulate latency with optional SOCKS5 proxy -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn say_after_with_tokio(ms: u16, who: String, url: String, socks5_proxy: Option, header_key: Option, header_value: Option) -> String { // Create HTTP client with optional SOCKS5 proxy let client = if let Some(proxy_url) = socks5_proxy { @@ -136,5 +252,60 @@ pub async fn say_after_with_tokio(ms: u16, who: String, url: String, socks5_prox format!("Hello, {who}! Your IP address is: {page_content} (with Tokio after {ms}ms delay)") } +// Factory functions for creating nodes as Arc +// These enable polymorphic access in Kotlin/Swift without manual wrapper code + +use std::sync::Arc; + +/// Create a Strike node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_strike_node(config: strike::StrikeConfig) -> Arc { + Arc::new(strike::StrikeNode::new(config)) +} + +/// Create a Speed node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_speed_node(config: speed::SpeedConfig) -> Arc { + Arc::new(speed::SpeedNode::new(config)) +} + +/// Create a Blink node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_blink_node(config: blink::BlinkConfig) -> Arc { + Arc::new(blink::BlinkNode::new(config)) +} + +/// Create a Phoenixd node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_phoenixd_node(config: phoenixd::PhoenixdConfig) -> Arc { + Arc::new(phoenixd::PhoenixdNode::new(config)) +} + +/// Create a CLN node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_cln_node(config: cln::ClnConfig) -> Arc { + Arc::new(cln::ClnNode::new(config)) +} + +/// Create an LND node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_lnd_node(config: lnd::LndConfig) -> Arc { + Arc::new(lnd::LndNode::new(config)) +} + +/// Create an NWC node as a polymorphic LightningNode +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn create_nwc_node(config: nwc::NwcConfig) -> Arc { + Arc::new(nwc::NwcNode::new(config)) +} + +/// Create a Spark node as a polymorphic LightningNode +#[cfg(feature = "spark")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +pub async fn create_spark_node(config: spark::SparkConfig) -> Result, ApiError> { + let node = spark::SparkNode::new(config).await?; + Ok(Arc::new(node)) +} + #[cfg(feature = "uniffi")] uniffi::setup_scaffolding!(); diff --git a/crates/lni/lnd/api.rs b/crates/lni/lnd/api.rs index 675024c..fb018cc 100644 --- a/crates/lni/lnd/api.rs +++ b/crates/lni/lnd/api.rs @@ -108,7 +108,7 @@ fn process_node_info_responses( } // Async version following the same pattern as say_after_with_tokio -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn get_info(config: LndConfig) -> Result { // Create HTTP client using the helper function let client = async_client(&config); @@ -195,7 +195,7 @@ pub async fn pay_offer( } // Async version of lookup_invoice following the same pattern as get_info_async -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn lookup_invoice( config: LndConfig, payment_hash: Option, @@ -340,11 +340,11 @@ pub async fn poll_invoice_events( } } -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn on_invoice_events( config: LndConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(&config, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), @@ -356,7 +356,7 @@ pub async fn on_invoice_events( } // Async version of create_invoice -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn create_invoice( config: LndConfig, params: CreateInvoiceParams, @@ -412,7 +412,7 @@ pub async fn create_invoice( }) } -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn pay_invoice( config: LndConfig, params: PayInvoiceParams, @@ -500,7 +500,7 @@ pub async fn pay_invoice( } // Async version of decode -#[uniffi::export(async_runtime = "tokio")] +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] pub async fn decode(config: LndConfig, invoice_str: String) -> Result { let client = async_client(&config); @@ -522,7 +522,7 @@ pub async fn decode(config: LndConfig, invoice_str: String) -> Result, diff --git a/crates/lni/lnd/lib.rs b/crates/lni/lnd/lib.rs index cd97b30..fa9f4ad 100644 --- a/crates/lni/lnd/lib.rs +++ b/crates/lni/lnd/lib.rs @@ -3,9 +3,11 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ - ApiError, CreateInvoiceParams, CreateOfferParams, LightningNode, ListTransactionsParams, LookupInvoiceParams, + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -47,34 +49,35 @@ impl LndNode { Self { config } } } + +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for LndNode { - async fn get_info(&self) -> Result { +impl LndNode { + pub async fn get_info(&self) -> Result { crate::lnd::api::get_info(self.config.clone()).await } - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { crate::lnd::api::create_invoice(self.config.clone(), params).await } - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { crate::lnd::api::pay_invoice(self.config.clone(), params).await } - async fn create_offer(&self, _params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not implemented for LndNode".to_string() }) } - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::lnd::api::get_offer(&self.config, search).await } - async fn list_offers(&self, search: Option) -> Result, ApiError> { + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { crate::lnd::api::list_offers(&self.config, search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -83,7 +86,7 @@ impl LightningNode for LndNode { crate::lnd::api::pay_offer(&self.config, offer, amount_msats, payer_note).await } - async fn lookup_invoice( + pub async fn lookup_invoice( &self, params: LookupInvoiceParams, ) -> Result { @@ -97,7 +100,7 @@ impl LightningNode for LndNode { .await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { @@ -110,19 +113,23 @@ impl LightningNode for LndNode { .await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::lnd::api::decode(self.config.clone(), str).await } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { crate::lnd::api::on_invoice_events(self.config.clone(), params, callback).await } } +// Trait implementation for Rust consumers - uses the impl_lightning_node macro +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(LndNode); + #[cfg(test)] mod tests { use crate::{InvoiceType, PayInvoiceParams}; @@ -206,7 +213,7 @@ mod tests { // BOLT11 match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), description: Some(description.clone()), description_hash: Some(description_hash.clone()), @@ -231,7 +238,7 @@ mod tests { // BOLT 11 with blinded paths match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), description: Some(description.clone()), description_hash: Some(description_hash.clone()), @@ -259,7 +266,7 @@ mod tests { // Simple BOLT11 test match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), ..Default::default() }) @@ -457,7 +464,7 @@ mod tests { }; // Start the event listener - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; // Check if events were received let received_events = events.lock().unwrap(); diff --git a/crates/lni/nwc/api.rs b/crates/lni/nwc/api.rs index 51bdf2e..f690b4a 100644 --- a/crates/lni/nwc/api.rs +++ b/crates/lni/nwc/api.rs @@ -306,7 +306,7 @@ pub async fn poll_invoice_events( pub async fn on_invoice_events( config: NwcConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(&config, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), diff --git a/crates/lni/nwc/lib.rs b/crates/lni/nwc/lib.rs index 83a63b7..46e105f 100644 --- a/crates/lni/nwc/lib.rs +++ b/crates/lni/nwc/lib.rs @@ -3,9 +3,11 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ - ApiError, CreateInvoiceParams, CreateOfferParams, LightningNode, ListTransactionsParams, LookupInvoiceParams, + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -47,34 +49,34 @@ impl NwcNode { } } -#[cfg_attr(feature = "uniffi", uniffi::export)] -#[async_trait::async_trait] -impl LightningNode for NwcNode { - async fn get_info(&self) -> Result { +// All node methods - UniFFI exports these directly when the feature is enabled +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +impl NwcNode { + pub async fn get_info(&self) -> Result { crate::nwc::api::get_info(self.config.clone()).await } - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { crate::nwc::api::create_invoice(self.config.clone(), params).await } - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { crate::nwc::api::pay_invoice(self.config.clone(), params).await } - async fn create_offer(&self, _params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not implemented for NwcNode".to_string() }) } - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::nwc::api::get_offer(&self.config, search).await } - async fn list_offers(&self, search: Option) -> Result, ApiError> { + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { crate::nwc::api::list_offers(&self.config, search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -83,30 +85,34 @@ impl LightningNode for NwcNode { crate::nwc::api::pay_offer(&self.config, offer, amount_msats, payer_note).await } - async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + pub async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { crate::nwc::api::lookup_invoice(self.config.clone(), params.payment_hash, params.search).await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { crate::nwc::api::list_transactions(self.config.clone(), params).await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::nwc::api::decode(self.config.clone(), str).await } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { crate::nwc::api::on_invoice_events(self.config.clone(), params, callback).await } } +// Trait implementation for Rust consumers - uses the impl_lightning_node macro +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(NwcNode); + #[cfg(test)] mod tests { use crate::InvoiceType; @@ -159,7 +165,6 @@ mod tests { let expiry = 3600; match NODE.create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, amount_msats: Some(amount_msats), description: Some(description.clone()), expiry: Some(expiry), @@ -290,7 +295,7 @@ mod tests { }; // Start the event listener - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; // Check if events were received let received_events = events.lock().unwrap(); diff --git a/crates/lni/phoenixd/api.rs b/crates/lni/phoenixd/api.rs index e0d1e52..476594f 100644 --- a/crates/lni/phoenixd/api.rs +++ b/crates/lni/phoenixd/api.rs @@ -368,7 +368,10 @@ pub async fn pay_offer( } // TODO implement list_offers, currently just one is returned by Phoenixd -pub fn list_offers() -> Result, ApiError> { +pub async fn list_offers( + _config: PhoenixdConfig, + _search: Option, +) -> Result, ApiError> { Ok(vec![]) } @@ -655,7 +658,7 @@ pub async fn poll_invoice_events( pub async fn on_invoice_events( config: PhoenixdConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(config, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), diff --git a/crates/lni/phoenixd/lib.rs b/crates/lni/phoenixd/lib.rs index 9f889a4..6e7fa4a 100644 --- a/crates/lni/phoenixd/lib.rs +++ b/crates/lni/phoenixd/lib.rs @@ -3,8 +3,10 @@ use napi_derive::napi; use crate::{ phoenixd::api::*, ApiError, ListTransactionsParams, PayInvoiceParams, PayInvoiceResponse, - Transaction, LightningNode, CreateOfferParams + Transaction, CreateOfferParams }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; use crate::{CreateInvoiceParams, LookupInvoiceParams, Offer}; @@ -49,17 +51,17 @@ impl PhoenixdNode { } } +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for PhoenixdNode { - async fn get_info(&self) -> Result { +impl PhoenixdNode { + pub async fn get_info(&self) -> Result { crate::phoenixd::api::get_info(self.config.clone()).await } - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { create_invoice( self.config.clone(), - params.invoice_type, + params.get_invoice_type(), Some(params.amount_msats.unwrap_or_default()), params.description, params.description_hash, @@ -67,23 +69,23 @@ impl LightningNode for PhoenixdNode { ).await } - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { pay_invoice(self.config.clone(), params).await } - async fn create_offer(&self, params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, params: CreateOfferParams) -> Result { crate::phoenixd::api::create_offer(self.config.clone(), params).await } - async fn get_offer(&self, _search: Option) -> Result { + pub async fn get_offer(&self, _search: Option) -> Result { crate::phoenixd::api::get_offer(self.config.clone()).await } - async fn list_offers(&self, _search: Option) -> Result, ApiError> { - crate::phoenixd::api::list_offers() + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { + crate::phoenixd::api::list_offers(self.config.clone(), search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -92,7 +94,7 @@ impl LightningNode for PhoenixdNode { crate::phoenixd::api::pay_offer(self.config.clone(), offer, amount_msats, payer_note).await } - async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + pub async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { crate::phoenixd::api::lookup_invoice( self.config.clone(), params.payment_hash, @@ -102,26 +104,30 @@ impl LightningNode for PhoenixdNode { ).await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { crate::phoenixd::api::list_transactions(self.config.clone(), params).await } - async fn decode(&self, _str: String) -> Result { + pub async fn decode(&self, _str: String) -> Result { Ok("".to_string()) } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { crate::phoenixd::api::on_invoice_events(self.config.clone(), params, callback).await } } +// Trait implementation for Rust consumers - uses the impl_lightning_node macro +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(PhoenixdNode); + #[cfg(test)] mod tests { use crate::InvoiceType; @@ -179,7 +185,7 @@ mod tests { let description_hash = "".to_string(); let expiry = 3600; let params = CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), offer: None, description: Some(description), @@ -278,6 +284,18 @@ mod tests { } } + #[tokio::test] + async fn test_list_offers() { + match NODE.list_offers(None).await { + Ok(offers) => { + println!("List offers resp: {:?}", offers); + } + Err(e) => { + panic!("Failed to list offers: {:?}", e); + } + } + } + #[tokio::test] async fn test_pay_offer() { match NODE.pay_offer( @@ -353,6 +371,6 @@ mod tests { ..Default::default() }; let callback = OnInvoiceEventCallback {}; - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; } } diff --git a/crates/lni/spark/api.rs b/crates/lni/spark/api.rs index 1003d5e..babc20d 100644 --- a/crates/lni/spark/api.rs +++ b/crates/lni/spark/api.rs @@ -45,7 +45,7 @@ pub async fn create_invoice( sdk: Arc, invoice_params: CreateInvoiceParams, ) -> Result { - match invoice_params.invoice_type { + match invoice_params.get_invoice_type() { InvoiceType::Bolt11 => { let response = sdk .receive_payment(ReceivePaymentRequest { @@ -424,7 +424,7 @@ pub async fn poll_invoice_events( pub async fn on_invoice_events( sdk: Arc, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(sdk, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), diff --git a/crates/lni/spark/lib.rs b/crates/lni/spark/lib.rs index ebb397b..17f5b32 100644 --- a/crates/lni/spark/lib.rs +++ b/crates/lni/spark/lib.rs @@ -9,9 +9,11 @@ use breez_sdk_spark::{ use crate::types::NodeInfo; use crate::{ - ApiError, CreateInvoiceParams, CreateOfferParams, LightningNode, ListTransactionsParams, + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -56,6 +58,7 @@ impl SparkConfig { // Note: SparkNode cannot use napi(object) because BreezSdk has private fields // #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +#[derive(Clone)] pub struct SparkNode { pub config: SparkConfig, sdk: Arc, @@ -134,29 +137,29 @@ impl SparkNode { } } +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for SparkNode { - async fn get_info(&self) -> Result { +impl SparkNode { + pub async fn get_info(&self) -> Result { let network = self.config.network.as_deref().unwrap_or("mainnet"); crate::spark::api::get_info(self.sdk.clone(), network).await } - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { crate::spark::api::create_invoice(self.sdk.clone(), params).await } - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { crate::spark::api::pay_invoice(self.sdk.clone(), params).await } - async fn create_offer(&self, _params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not yet implemented for SparkNode".to_string(), }) } - async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + pub async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { crate::spark::api::lookup_invoice( self.sdk.clone(), params.payment_hash, @@ -167,7 +170,7 @@ impl LightningNode for SparkNode { .await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { @@ -180,27 +183,27 @@ impl LightningNode for SparkNode { .await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::spark::api::decode(self.sdk.clone(), str).await } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { crate::spark::api::on_invoice_events(self.sdk.clone(), params, callback).await } - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::spark::api::get_offer(search) } - async fn list_offers(&self, search: Option) -> Result, ApiError> { + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { crate::spark::api::list_offers(search) } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -210,6 +213,9 @@ impl LightningNode for SparkNode { } } +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(SparkNode); + #[cfg(test)] mod tests { use super::*; @@ -313,7 +319,7 @@ mod tests { let node = get_node().await.expect("Failed to connect"); let params = CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(1000), offer: None, description: Some("Test invoice".to_string()), @@ -499,7 +505,7 @@ mod tests { ..Default::default() }; let callback = TestInvoiceEventCallback; - node.on_invoice_events(params, Box::new(callback)).await; + node.on_invoice_events(params, Arc::new(callback)).await; let _ = node.disconnect().await; } } diff --git a/crates/lni/speed/api.rs b/crates/lni/speed/api.rs index 539323d..1bc614e 100644 --- a/crates/lni/speed/api.rs +++ b/crates/lni/speed/api.rs @@ -136,7 +136,7 @@ pub async fn create_invoice( config: &SpeedConfig, invoice_params: CreateInvoiceParams, ) -> Result { - match invoice_params.invoice_type { + match invoice_params.get_invoice_type() { InvoiceType::Bolt11 => { let client = client(config); @@ -575,7 +575,7 @@ where pub async fn on_invoice_events( config: SpeedConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(&config, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), diff --git a/crates/lni/speed/lib.rs b/crates/lni/speed/lib.rs index 897d353..7a82779 100644 --- a/crates/lni/speed/lib.rs +++ b/crates/lni/speed/lib.rs @@ -1,11 +1,12 @@ #[cfg(feature = "napi_rs")] use napi_derive::napi; -use crate::types::{ListTransactionsParams, LookupInvoiceParams, NodeInfo}; +use crate::types::{ListTransactionsParams, LookupInvoiceParams, NodeInfo, OnInvoiceEventCallback, OnInvoiceEventParams}; use crate::{ - ApiError, CreateInvoiceParams, CreateOfferParams, LightningNode, OnInvoiceEventCallback, OnInvoiceEventParams, - Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, + ApiError, CreateInvoiceParams, CreateOfferParams, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -50,32 +51,32 @@ impl SpeedNode { } } +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for SpeedNode { - async fn get_info(&self) -> Result { +impl SpeedNode { + pub async fn get_info(&self) -> Result { crate::speed::api::get_info(&self.config).await } - async fn create_invoice( + pub async fn create_invoice( &self, - invoice_params: CreateInvoiceParams, + params: CreateInvoiceParams, ) -> Result { - crate::speed::api::create_invoice(&self.config, invoice_params).await + crate::speed::api::create_invoice(&self.config, params).await } - async fn pay_invoice( + pub async fn pay_invoice( &self, - invoice_params: PayInvoiceParams, + params: PayInvoiceParams, ) -> Result { - crate::speed::api::pay_invoice(&self.config, invoice_params).await + crate::speed::api::pay_invoice(&self.config, params).await } - async fn create_offer(&self, _params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not implemented for SpeedNode".to_string() }) } - async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { + pub async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result { crate::speed::api::lookup_invoice( &self.config, params.payment_hash, @@ -86,7 +87,7 @@ impl LightningNode for SpeedNode { .await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { @@ -94,19 +95,19 @@ impl LightningNode for SpeedNode { .await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::speed::api::decode(&self.config, str).await } - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::speed::api::get_offer(&self.config, search).await } - async fn list_offers(&self, search: Option) -> Result, ApiError> { + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { crate::speed::api::list_offers(&self.config, search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -115,15 +116,19 @@ impl LightningNode for SpeedNode { crate::speed::api::pay_offer(&self.config, offer, amount_msats, payer_note).await } - async fn on_invoice_events( + pub async fn on_invoice_events( &self, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { crate::speed::api::on_invoice_events(self.config.clone(), params, callback).await; } } +// Trait implementation for Rust consumers - uses the impl_lightning_node macro +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(SpeedNode); + #[cfg(test)] mod tests { use super::*; @@ -177,7 +182,7 @@ mod tests { #[tokio::test] async fn test_create_invoice() { let params = CreateInvoiceParams { - invoice_type: crate::InvoiceType::Bolt11, + invoice_type: Some(crate::InvoiceType::Bolt11), amount_msats: Some(1000), // 1 sat description: Some("Test invoice".to_string()), description_hash: None, @@ -314,7 +319,7 @@ mod tests { search: Some(TEST_PAYMENT_REQUEST.to_string()), // Also provide the withdraw_request as search term }; - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; // Check that some events were captured let events_guard = events.lock().unwrap(); diff --git a/crates/lni/strike/api.rs b/crates/lni/strike/api.rs index b0b867c..18ace76 100644 --- a/crates/lni/strike/api.rs +++ b/crates/lni/strike/api.rs @@ -131,7 +131,7 @@ pub async fn create_invoice( ) -> Result { let client = async_client(&config); - match invoice_params.invoice_type { + match invoice_params.get_invoice_type() { InvoiceType::Bolt11 => { // Create a receive request with bolt11 configuration let req_url = format!("{}/receive-requests", get_base_url(&config)); @@ -347,7 +347,7 @@ pub fn get_offer(_config: &StrikeConfig, _search: Option) -> Result, ) -> Result, ApiError> { @@ -686,7 +686,7 @@ where pub async fn on_invoice_events( config: StrikeConfig, params: OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ) { poll_invoice_events(config, params, move |status, tx| match status.as_str() { "success" => callback.success(tx), diff --git a/crates/lni/strike/lib.rs b/crates/lni/strike/lib.rs index 333f09f..94aa1e5 100644 --- a/crates/lni/strike/lib.rs +++ b/crates/lni/strike/lib.rs @@ -3,9 +3,11 @@ use napi_derive::napi; use crate::types::NodeInfo; use crate::{ - ApiError, CreateInvoiceParams, CreateOfferParams, LightningNode, ListTransactionsParams, LookupInvoiceParams, + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -50,26 +52,26 @@ impl StrikeNode { } } +// All node methods - UniFFI exports these directly when the feature is enabled #[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] -#[async_trait::async_trait] -impl LightningNode for StrikeNode { - async fn get_info(&self) -> Result { +impl StrikeNode { + pub async fn get_info(&self) -> Result { crate::strike::api::get_info(self.config.clone()).await } - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { + pub async fn create_invoice(&self, params: CreateInvoiceParams) -> Result { crate::strike::api::create_invoice(self.config.clone(), params).await } - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { + pub async fn pay_invoice(&self, params: PayInvoiceParams) -> Result { crate::strike::api::pay_invoice(self.config.clone(), params).await } - async fn create_offer(&self, _params: CreateOfferParams) -> Result { + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { Err(ApiError::Api { reason: "create_offer not implemented for StrikeNode".to_string() }) } - async fn lookup_invoice( + pub async fn lookup_invoice( &self, params: LookupInvoiceParams, ) -> Result { @@ -83,7 +85,7 @@ impl LightningNode for StrikeNode { .await } - async fn list_transactions( + pub async fn list_transactions( &self, params: ListTransactionsParams, ) -> Result, ApiError> { @@ -96,27 +98,19 @@ impl LightningNode for StrikeNode { .await } - async fn decode(&self, str: String) -> Result { + pub async fn decode(&self, str: String) -> Result { crate::strike::api::decode(&self.config, str) } - async fn on_invoice_events( - &self, - params: crate::types::OnInvoiceEventParams, - callback: Box, - ) { - crate::strike::api::on_invoice_events(self.config.clone(), params, callback).await - } - - async fn get_offer(&self, search: Option) -> Result { + pub async fn get_offer(&self, search: Option) -> Result { crate::strike::api::get_offer(&self.config, search) } - async fn list_offers(&self, search: Option) -> Result, ApiError> { - crate::strike::api::list_offers(&self.config, search) + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { + crate::strike::api::list_offers(&self.config, search).await } - async fn pay_offer( + pub async fn pay_offer( &self, offer: String, amount_msats: i64, @@ -124,8 +118,19 @@ impl LightningNode for StrikeNode { ) -> Result { crate::strike::api::pay_offer(&self.config, offer, amount_msats, payer_note) } + + pub async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: std::sync::Arc, + ) { + crate::strike::api::on_invoice_events(self.config.clone(), params, callback).await + } } +// Trait implementation for polymorphic access via Arc +crate::impl_lightning_node!(StrikeNode); + #[cfg(test)] mod tests { use super::*; @@ -184,7 +189,7 @@ mod tests { match NODE .create_invoice(CreateInvoiceParams { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), amount_msats: Some(amount_msats), description: Some(description.clone()), expiry: Some(expiry), @@ -329,7 +334,7 @@ mod tests { }; // Start the event listener - NODE.on_invoice_events(params, Box::new(callback)).await; + NODE.on_invoice_events(params, std::sync::Arc::new(callback)).await; // Check that some events were captured let events_guard = events.lock().unwrap(); diff --git a/crates/lni/types.rs b/crates/lni/types.rs index b06e876..6f52fc8 100644 --- a/crates/lni/types.rs +++ b/crates/lni/types.rs @@ -3,8 +3,10 @@ use napi_derive::napi; use serde::{Deserialize, Serialize}; use async_trait::async_trait; -use crate::{cln::ClnNode, lnd::LndNode, phoenixd::PhoenixdNode, nwc::NwcNode, ApiError}; +use crate::{cln::ClnNode, lnd::LndNode, phoenixd::PhoenixdNode, nwc::NwcNode}; +/// Enum for polymorphic node access in non-UniFFI builds +#[cfg(not(feature = "uniffi"))] pub enum LightningNodeEnum { Phoenixd(PhoenixdNode), Lnd(LndNode), @@ -12,36 +14,41 @@ pub enum LightningNodeEnum { Nwc(NwcNode), } +/// The core LightningNode trait for polymorphic node operations. +/// This trait is exported to UniFFI, allowing Kotlin/Swift to work with +/// `Arc` directly without manual wrapper code. +#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))] #[async_trait] -pub trait LightningNode { - async fn get_info(&self) -> Result; - async fn create_invoice(&self, params: CreateInvoiceParams) -> Result; - async fn pay_invoice(&self, params: PayInvoiceParams) -> Result; - async fn create_offer(&self, params: CreateOfferParams) -> Result; - async fn get_offer(&self, search: Option) -> Result; - async fn list_offers(&self, search: Option) -> Result, ApiError>; +pub trait LightningNode: Send + Sync { + async fn get_info(&self) -> Result; + async fn create_invoice(&self, params: CreateInvoiceParams) -> Result; + async fn pay_invoice(&self, params: PayInvoiceParams) -> Result; + async fn create_offer(&self, params: CreateOfferParams) -> Result; + async fn get_offer(&self, search: Option) -> Result; + async fn list_offers(&self, search: Option) -> Result, crate::ApiError>; async fn pay_offer( &self, offer: String, amount_msats: i64, payer_note: Option, - ) -> Result; - async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result; + ) -> Result; + async fn lookup_invoice(&self, params: LookupInvoiceParams) -> Result; async fn list_transactions( &self, params: ListTransactionsParams, - ) -> Result, ApiError>; - async fn decode(&self, str: String) -> Result; + ) -> Result, crate::ApiError>; + async fn decode(&self, str: String) -> Result; async fn on_invoice_events( &self, params: crate::types::OnInvoiceEventParams, - callback: Box, + callback: std::sync::Arc, ); } #[cfg_attr(feature = "napi_rs", napi(string_enum))] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] #[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "napi_rs"), derive(Clone))] pub enum InvoiceType { Bolt11, Bolt12, @@ -314,23 +321,35 @@ impl Default for LookupInvoiceParams { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug, Serialize, Deserialize)] pub struct CreateInvoiceParams { - pub invoice_type: InvoiceType, + /// Defaults to Bolt11 if not specified + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub invoice_type: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = None))] pub amount_msats: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = None))] pub offer: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = None))] pub description: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = None))] pub description_hash: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = None))] pub expiry: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = None))] pub r_preimage: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = Some(false)))] pub is_blinded: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = Some(false)))] pub is_keysend: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = Some(false)))] pub is_amp: Option, + #[cfg_attr(feature = "uniffi", uniffi(default = Some(false)))] pub is_private: Option, // pub route_hints: Option>, TODO } impl Default for CreateInvoiceParams { fn default() -> Self { Self { - invoice_type: InvoiceType::Bolt11, + invoice_type: Some(InvoiceType::Bolt11), // Defaults to Bolt11 when used amount_msats: None, offer: None, description: None, @@ -345,6 +364,13 @@ impl Default for CreateInvoiceParams { } } +impl CreateInvoiceParams { + /// Get the invoice type, defaulting to Bolt11 if not specified + pub fn get_invoice_type(&self) -> InvoiceType { + self.invoice_type.clone().unwrap_or(InvoiceType::Bolt11) + } +} + // Offer aka BOLT12 Offer #[cfg_attr(feature = "napi_rs", napi(object))] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -410,7 +436,9 @@ impl Default for PayInvoiceParams { } // Define the callback trait for UniFFI -#[cfg_attr(feature = "uniffi", uniffi::export(callback_interface))] +// Using with_foreign allows foreign languages (Kotlin/Swift) to implement this trait +// and pass it to Rust. This is the newer approach vs callback_interface. +#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))] pub trait OnInvoiceEventCallback: Send + Sync { fn success(&self, transaction: Option); fn pending(&self, transaction: Option); From 0ed4f8109f3ed2d17016ab37f403879096d58f57 Mon Sep 17 00:00:00 2001 From: nicktee Date: Sat, 10 Jan 2026 12:53:20 -0600 Subject: [PATCH 06/19] add bip39 and spark test --- crates/lni/Cargo.toml | 2 ++ crates/lni/lib.rs | 33 +++++++++++++++++++++++++++ crates/lni/spark/lib.rs | 49 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/crates/lni/Cargo.toml b/crates/lni/Cargo.toml index 2d8225f..885d152 100644 --- a/crates/lni/Cargo.toml +++ b/crates/lni/Cargo.toml @@ -40,11 +40,13 @@ nostr = "0.43.0" chrono = { version = "0.4", features = ["serde"] } once_cell = "1.19" breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3", optional = true } +bip39 = "2.2.2" [dev-dependencies] async-attributes = "1.1.1" tokio = { version = "1", features = ["full"] } uniffi = { version = "0.29.0", features = ["bindgen-tests"] } +bip39 = "2.2.2" [build-dependencies] uniffi = { version = "0.29.0", features = ["build"] } diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 2df67a5..2d50015 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -35,6 +35,39 @@ impl From for ApiError { } } +/// Generate a new BIP39 mnemonic phrase for wallet creation. +/// Uses cryptographically secure randomness from the OS. +/// +/// # Arguments +/// * `word_count` - Number of words: 12 (default) or 24. If None or invalid, defaults to 12. +/// +/// # Returns +/// A space-separated mnemonic phrase +#[cfg_attr(feature = "napi_rs", napi)] +#[cfg_attr(feature = "uniffi", uniffi::export)] +pub fn generate_mnemonic(word_count: Option) -> Result { + use bip39::{Language, Mnemonic}; + use rand::rngs::OsRng; + use rand::RngCore; + + // 16 bytes = 128 bits = 12 words + // 32 bytes = 256 bits = 24 words + let entropy_size = match word_count { + Some(24) => 32, + _ => 16, // Default to 12 words + }; + + let mut entropy = vec![0u8; entropy_size]; + OsRng.fill_bytes(&mut entropy); + + let mnemonic = Mnemonic::from_entropy_in(Language::English, &entropy) + .map_err(|e| ApiError::Api { + reason: format!("Failed to generate mnemonic: {}", e), + })?; + + Ok(mnemonic.to_string()) +} + /// Macro to implement LightningNode trait by delegating to inherent methods. /// This avoids code duplication between UniFFI exports and trait implementations. /// The macro works for both UniFFI and non-UniFFI builds. diff --git a/crates/lni/spark/lib.rs b/crates/lni/spark/lib.rs index 17f5b32..86f6f58 100644 --- a/crates/lni/spark/lib.rs +++ b/crates/lni/spark/lib.rs @@ -508,4 +508,53 @@ mod tests { node.on_invoice_events(params, Arc::new(callback)).await; let _ = node.disconnect().await; } + + #[tokio::test] + async fn test_create_new_wallet_and_invoice() { + // Skip if no API key - we still need the API key for mainnet + if API_KEY.is_empty() { + println!("Skipping test: SPARK_API_KEY not set"); + return; + } + + // 1. Generate a fresh mnemonic (creates a brand new wallet) + let mnemonic_str = crate::generate_mnemonic(Some(12)).expect("Failed to generate mnemonic"); + println!("Generated new mnemonic: {}", mnemonic_str); + + // Verify it's a 12-word mnemonic + let word_count = mnemonic_str.split_whitespace().count(); + assert_eq!(word_count, 12, "Expected 12-word mnemonic, got {}", word_count); + + // 2. Create a new SparkNode with the fresh mnemonic + let config = SparkConfig { + mnemonic: mnemonic_str, + api_key: Some(API_KEY.clone()), + storage_dir: format!("{}/new_wallet_{}", STORAGE_DIR.clone(), uuid::Uuid::new_v4()), + network: Some("mainnet".to_string()), + passphrase: None, + }; + + let node = SparkNode::new(config).await.expect("Failed to connect with new wallet"); + + // 3. Create an invoice with the new wallet + let invoice_params = CreateInvoiceParams { + amount_msats: Some(1000), // 1 sat + description: Some("Test invoice from new wallet".to_string()), + expiry: Some(3600), + invoice_type: Some(InvoiceType::Bolt11), + ..Default::default() + }; + + let invoice = node.create_invoice(invoice_params).await.expect("Failed to create invoice"); + + println!("Created invoice: {}", invoice.invoice); + assert!(!invoice.invoice.is_empty(), "Invoice should not be empty"); + assert!( + invoice.invoice.starts_with("lnbc") || invoice.invoice.starts_with("lntb"), + "Invoice should be a valid bolt11 invoice" + ); + + // 4. Clean up + let _ = node.disconnect().await; + } } From ce2e0df9621702c7910dbdc31be304d6370857e3 Mon Sep 17 00:00:00 2001 From: nicktee Date: Sat, 10 Jan 2026 21:13:22 -0600 Subject: [PATCH 07/19] generateMnemonic kotlin, nodejs, and nodejs --- bindings/kotlin/README.md | 22 ++++++ bindings/kotlin/build.sh | 23 ++++-- .../kotlin/com/lni/example/MainActivity.kt | 70 +++++++++++++++++++ bindings/lni_nodejs/Cargo.toml | 2 + bindings/lni_nodejs/index.d.ts | 7 ++ bindings/lni_nodejs/index.js | 3 +- bindings/lni_nodejs/main.mjs | 18 ++++- bindings/lni_nodejs/src/lib.rs | 24 +++++++ bindings/swift/README.md | 14 ++-- bindings/swift/Sources/LNI/lni.swift | 60 ++++++++++++++++ bindings/swift/build.sh | 27 +++++-- .../LNIExample.xcodeproj/project.pbxproj | 12 +++- .../LNIExample/LNIExample/ContentView.swift | 45 ++++++++++++ bindings/swift/example/LNIExample/lni.swift | 60 ++++++++++++++++ crates/lni/lib.rs | 6 +- 15 files changed, 365 insertions(+), 28 deletions(-) diff --git a/bindings/kotlin/README.md b/bindings/kotlin/README.md index 8e995eb..eb96983 100644 --- a/bindings/kotlin/README.md +++ b/bindings/kotlin/README.md @@ -84,6 +84,28 @@ node.close() See the `example/` directory for a complete Android example project. +### Building for Android + +```bash +./build.sh --release +``` + +This builds native libraries for all Android targets (arm64-v8a, armeabi-v7a, x86_64, x86) and copies them to the example project's `jniLibs` directory. + +To skip Android builds (only generate Kotlin bindings): + +```bash +./build.sh --release --no-android +``` + +### Important: Invalidate Caches + +After updating native libraries, you may need to invalidate Android Studio caches: + +**File → Invalidate Caches → Invalidate and Restart** + +This ensures Android Studio picks up the updated native libraries. + ### Adding to your Android project 1. Copy the generated `lni.kt` file to your project diff --git a/bindings/kotlin/build.sh b/bindings/kotlin/build.sh index 71d767e..63186e9 100755 --- a/bindings/kotlin/build.sh +++ b/bindings/kotlin/build.sh @@ -5,10 +5,11 @@ # This script: # 1. Builds the lni library with uniffi feature # 2. Uses uniffi-bindgen to generate Kotlin bindings from the shared library -# 3. Optionally builds for Android targets +# 3. Builds for Android targets (default, use --no-android to skip) # -# Usage: ./build.sh [--release] [--android] -# ./build.sh --release --android # Build for Android in release mode +# Usage: ./build.sh [--release] [--no-android] +# ./build.sh --release # Build for Android in release mode +# ./build.sh --release --no-android # Skip Android builds set -e @@ -18,15 +19,15 @@ EXAMPLE_DIR="$SCRIPT_DIR/example" # Parse arguments BUILD_TYPE="debug" -BUILD_ANDROID=false +BUILD_ANDROID=true for arg in "$@"; do case $arg in --release) BUILD_TYPE="release" ;; - --android) - BUILD_ANDROID=true + --no-android) + BUILD_ANDROID=false ;; esac done @@ -111,3 +112,13 @@ echo "Kotlin bindings generated successfully in: $OUTPUT_DIR" echo "" echo "Generated files:" ls -la "$OUTPUT_DIR" + +if [ "$BUILD_ANDROID" = true ]; then + echo "" + echo "============================================================" + echo "IMPORTANT: After updating native libraries, you may need to" + echo "invalidate Android Studio caches:" + echo "" + echo " File → Invalidate Caches → Invalidate and Restart" + echo "============================================================" +fi diff --git a/bindings/kotlin/example/app/src/main/kotlin/com/lni/example/MainActivity.kt b/bindings/kotlin/example/app/src/main/kotlin/com/lni/example/MainActivity.kt index d7e2227..57dc44c 100644 --- a/bindings/kotlin/example/app/src/main/kotlin/com/lni/example/MainActivity.kt +++ b/bindings/kotlin/example/app/src/main/kotlin/com/lni/example/MainActivity.kt @@ -40,6 +40,7 @@ fun LniExampleScreen() { var isLoading by remember { mutableStateOf(false) } var strikeApiKey by remember { mutableStateOf("") } var showApiKey by remember { mutableStateOf(false) } + var use24Words by remember { mutableStateOf(false) } // Invoice monitoring state var invoiceStatus by remember { mutableStateOf(null) } @@ -60,6 +61,47 @@ fun LniExampleScreen() { modifier = Modifier.padding(bottom = 16.dp) ) + // Wallet Utils Section + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Wallet Utils", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text("24 words (default: 12)") + Spacer(modifier = Modifier.weight(1f)) + Switch( + checked = use24Words, + onCheckedChange = { use24Words = it } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Button( + onClick = { + output = generateNewMnemonic(use24Words) + }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth() + ) { + Text("Generate Mnemonic") + } + } + } + // Strike API Key Section Card( modifier = Modifier @@ -306,6 +348,34 @@ suspend fun getStrikeBalance(apiKey: String): String = withContext(Dispatchers.I sb.toString() } +fun generateNewMnemonic(use24Words: Boolean): String { + val sb = StringBuilder() + sb.appendLine("=== Generate Mnemonic ===\n") + + try { + val wordCount: UByte? = if (use24Words) 24u else null + val mnemonic = generateMnemonic(wordCount) + + val words = mnemonic.split(" ") + sb.appendLine("✓ Generated ${words.size}-word mnemonic:\n") + + // Display words in a numbered list + words.forEachIndexed { index, word -> + sb.appendLine("${String.format("%2d", index + 1)}. $word") + } + + sb.appendLine("\n⚠️ IMPORTANT: In a real app, never display") + sb.appendLine(" the mnemonic on screen. Store it securely!") + + } catch (e: ApiException) { + sb.appendLine("✗ API Error: ${e.message}") + } catch (e: Exception) { + sb.appendLine("✗ Error: ${e.message}") + } + + return sb.toString() +} + suspend fun testStrike(): String = withContext(Dispatchers.IO) { val sb = StringBuilder() sb.appendLine("=== Strike Node Test (Polymorphic) ===\n") diff --git a/bindings/lni_nodejs/Cargo.toml b/bindings/lni_nodejs/Cargo.toml index be0f8c9..f3fde54 100644 --- a/bindings/lni_nodejs/Cargo.toml +++ b/bindings/lni_nodejs/Cargo.toml @@ -21,6 +21,8 @@ napi = { version = "2.16", default-features = false, features = ["napi4", "tokio napi-derive = "2.16" dotenv = "0.15.0" lazy_static = "1.4.0" +bip39 = "2.2.2" +rand = "0.8" [build-dependencies] diff --git a/bindings/lni_nodejs/index.d.ts b/bindings/lni_nodejs/index.d.ts index e4c90ee..0faf947 100644 --- a/bindings/lni_nodejs/index.d.ts +++ b/bindings/lni_nodejs/index.d.ts @@ -285,6 +285,13 @@ export interface Payment { updatedAt: number amountMsats: number } +/** + * Generate a BIP39 mnemonic phrase + * + * @param wordCount - Optional number of words (12 or 24). Defaults to 12. + * @returns A space-separated mnemonic phrase + */ +export declare function generateMnemonic(wordCount?: number | undefined | null): string export declare function sayAfterWithTokio(ms: number, who: string, url: string, socks5Proxy?: string | undefined | null, headerKey?: string | undefined | null, headerValue?: string | undefined | null): Promise export declare class PhoenixdNode { constructor(config: PhoenixdConfig) diff --git a/bindings/lni_nodejs/index.js b/bindings/lni_nodejs/index.js index 5159bd8..2e18253 100644 --- a/bindings/lni_nodejs/index.js +++ b/bindings/lni_nodejs/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { InvoiceType, PhoenixdNode, ClnNode, LndNode, BlinkNode, NwcNode, StrikeNode, SpeedNode, sayAfterWithTokio } = nativeBinding +const { InvoiceType, PhoenixdNode, ClnNode, LndNode, BlinkNode, NwcNode, StrikeNode, SpeedNode, generateMnemonic, sayAfterWithTokio } = nativeBinding module.exports.InvoiceType = InvoiceType module.exports.PhoenixdNode = PhoenixdNode @@ -320,4 +320,5 @@ module.exports.BlinkNode = BlinkNode module.exports.NwcNode = NwcNode module.exports.StrikeNode = StrikeNode module.exports.SpeedNode = SpeedNode +module.exports.generateMnemonic = generateMnemonic module.exports.sayAfterWithTokio = sayAfterWithTokio diff --git a/bindings/lni_nodejs/main.mjs b/bindings/lni_nodejs/main.mjs index 0bc3385..34d285f 100644 --- a/bindings/lni_nodejs/main.mjs +++ b/bindings/lni_nodejs/main.mjs @@ -1,4 +1,4 @@ -import { PhoenixdNode, ClnNode, LndNode, StrikeNode, BlinkNode, SpeedNode, NwcNode } from "./index.js"; +import { PhoenixdNode, ClnNode, LndNode, StrikeNode, BlinkNode, SpeedNode, NwcNode, generateMnemonic } from "./index.js"; import dotenv from "dotenv"; dotenv.config(); @@ -241,6 +241,22 @@ async function main() { // Show environment help showEnvironmentHelp(); + + // Test generateMnemonic + console.log("\n=== Testing generateMnemonic ==="); + try { + const mnemonic12 = generateMnemonic(); + console.log("12-word mnemonic:", mnemonic12); + console.log("Word count:", mnemonic12.split(" ").length); + + const mnemonic24 = generateMnemonic(24); + console.log("24-word mnemonic:", mnemonic24); + console.log("Word count:", mnemonic24.split(" ").length); + + console.log("generateMnemonic tests passed!"); + } catch (error) { + console.error("generateMnemonic test failed:", error.message); + } // await lnd(); // await strike(); diff --git a/bindings/lni_nodejs/src/lib.rs b/bindings/lni_nodejs/src/lib.rs index 245e3eb..29ec36d 100644 --- a/bindings/lni_nodejs/src/lib.rs +++ b/bindings/lni_nodejs/src/lib.rs @@ -31,6 +31,30 @@ pub use speed::SpeedNode; use std::time::Duration; +/// Generate a BIP39 mnemonic phrase +/// +/// @param wordCount - Optional number of words (12 or 24). Defaults to 12. +/// @returns A space-separated mnemonic phrase +#[napi] +pub fn generate_mnemonic(word_count: Option) -> napi::Result { + use bip39::{Language, Mnemonic}; + use rand::rngs::OsRng; + use rand::RngCore; + + let entropy_size = match word_count { + Some(24) => 32, + _ => 16, + }; + + let mut entropy = vec![0u8; entropy_size]; + OsRng.fill_bytes(&mut entropy); + + match Mnemonic::from_entropy_in(Language::English, &entropy) { + Ok(mnemonic) => Ok(mnemonic.to_string()), + Err(e) => Err(napi::Error::from_reason(format!("Failed to generate mnemonic: {}", e))), + } +} + // Make an HTTP request to get IP address and simulate latency with optional SOCKS5 proxy #[napi] pub async fn say_after_with_tokio(ms: u16, who: String, url: String, socks5_proxy: Option, header_key: Option, header_value: Option) -> napi::Result { diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 5678d45..3ada066 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -25,7 +25,7 @@ This package provides Swift bindings for LNI, allowing you to interact with vari - Xcode (for iOS builds) - iOS SDK (comes with Xcode) -### Generate Swift bindings +### Build for iOS ```bash ./build.sh --release @@ -34,18 +34,16 @@ This package provides Swift bindings for LNI, allowing you to interact with vari This will: 1. Build the LNI library with UniFFI support 2. Generate Swift bindings in `Sources/LNI/` +3. Build static libraries for iOS Simulator (arm64 + x86_64) +4. Build static library for iOS devices (arm64) +5. Create a universal XCFramework -### Build for iOS +To skip iOS builds (only generate Swift bindings): ```bash -./build.sh --release --ios +./build.sh --release --no-ios ``` -This will additionally: -1. Build static libraries for iOS Simulator (arm64 + x86_64) -2. Build static library for iOS devices (arm64) -3. Create a universal XCFramework - ## Usage ### Basic Example diff --git a/bindings/swift/Sources/LNI/lni.swift b/bindings/swift/Sources/LNI/lni.swift index af9f238..0c7092d 100644 --- a/bindings/swift/Sources/LNI/lni.swift +++ b/bindings/swift/Sources/LNI/lni.swift @@ -403,6 +403,22 @@ private let UNIFFI_CALLBACK_SUCCESS: Int32 = 0 private let UNIFFI_CALLBACK_ERROR: Int32 = 1 private let UNIFFI_CALLBACK_UNEXPECTED_ERROR: Int32 = 2 +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt8: FfiConverterPrimitive { + typealias FfiType = UInt8 + typealias SwiftType = UInt8 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt8 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: UInt8, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7249,6 +7265,30 @@ extension InvoiceType: Equatable, Hashable {} +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt8: FfiConverterRustBuffer { + typealias SwiftType = UInt8? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt8.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt8.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7694,6 +7734,23 @@ public func decode(config: LndConfig, invoiceStr: String)async throws -> String errorHandler: FfiConverterTypeApiError_lift ) } +/** + * Generate a new BIP39 mnemonic phrase for wallet creation. + * Uses cryptographically secure randomness from the OS. + * + * # Arguments + * * `word_count` - Number of words: 12 (default) or 24. If None or invalid, defaults to 12. + * + * # Returns + * A space-separated mnemonic phrase + */ +public func generateMnemonic(wordCount: UInt8?)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeApiError_lift) { + uniffi_lni_fn_func_generate_mnemonic( + FfiConverterOptionUInt8.lower(wordCount),$0 + ) +}) +} public func getInfo(config: LndConfig)async throws -> NodeInfo { return try await uniffiRustCallAsync( @@ -7823,6 +7880,9 @@ private let initializationResult: InitializationResult = { if (uniffi_lni_checksum_func_decode() != 11646) { return InitializationResult.apiChecksumMismatch } + if (uniffi_lni_checksum_func_generate_mnemonic() != 62024) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_lni_checksum_func_get_info() != 59600) { return InitializationResult.apiChecksumMismatch } diff --git a/bindings/swift/build.sh b/bindings/swift/build.sh index 591abea..a9db8b1 100755 --- a/bindings/swift/build.sh +++ b/bindings/swift/build.sh @@ -5,12 +5,13 @@ # This script: # 1. Builds the lni library with uniffi feature # 2. Uses uniffi-bindgen to generate Swift bindings from the shared library -# 3. Optionally builds for iOS simulator targets +# 3. Builds for iOS simulator and device targets (default, use --no-ios to skip) # 4. Optionally packages XCFramework for SPM distribution # -# Usage: ./build.sh [--release] [--ios] [--package] -# ./build.sh --release --ios # Build for iOS in release mode -# ./build.sh --release --ios --package # Build and package for SPM release +# Usage: ./build.sh [--release] [--no-ios] [--package] +# ./build.sh --release # Build for iOS in release mode +# ./build.sh --release --no-ios # Skip iOS builds +# ./build.sh --release --package # Build and package for SPM release set -e @@ -20,7 +21,7 @@ EXAMPLE_DIR="$SCRIPT_DIR/example" # Parse arguments BUILD_TYPE="debug" -BUILD_IOS=false +BUILD_IOS=true PACKAGE_RELEASE=false for arg in "$@"; do @@ -28,8 +29,8 @@ for arg in "$@"; do --release) BUILD_TYPE="release" ;; - --ios) - BUILD_IOS=true + --no-ios) + BUILD_IOS=false ;; --package) PACKAGE_RELEASE=true @@ -181,6 +182,18 @@ if [ "$BUILD_IOS" = true ]; then echo "" echo "XCFramework created successfully!" + + # Copy to example project + EXAMPLE_LNIEXAMPLE_DIR="$SCRIPT_DIR/example/LNIExample" + if [ -d "$EXAMPLE_LNIEXAMPLE_DIR" ]; then + echo "" + echo "Copying to example project..." + rm -rf "$EXAMPLE_LNIEXAMPLE_DIR/LNI.xcframework" + cp -R "$XCFRAMEWORK_DIR" "$EXAMPLE_LNIEXAMPLE_DIR/" + cp "$OUTPUT_DIR/lni.swift" "$EXAMPLE_LNIEXAMPLE_DIR/" + echo " Copied LNI.xcframework to $EXAMPLE_LNIEXAMPLE_DIR/" + echo " Copied lni.swift to $EXAMPLE_LNIEXAMPLE_DIR/" + fi # Package for release if requested if [ "$PACKAGE_RELEASE" = true ]; then diff --git a/bindings/swift/example/LNIExample/LNIExample.xcodeproj/project.pbxproj b/bindings/swift/example/LNIExample/LNIExample.xcodeproj/project.pbxproj index 0a31bc3..bcd7684 100644 --- a/bindings/swift/example/LNIExample/LNIExample.xcodeproj/project.pbxproj +++ b/bindings/swift/example/LNIExample/LNIExample.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ @@ -123,6 +123,9 @@ Base, ); mainGroup = 2A1B3CF42B0E0001000A0001; + packageReferences = ( + 0148E2E82F12DA7500A53246 /* XCLocalSwiftPackageReference "../../../../../lni" */, + ); productRefGroup = 2A1B3CFE2B0E0001000A0001 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -354,6 +357,13 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 0148E2E82F12DA7500A53246 /* XCLocalSwiftPackageReference "../../../../../lni" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../../../lni; + }; +/* End XCLocalSwiftPackageReference section */ }; rootObject = 2A1B3CF52B0E0001000A0001 /* Project object */; } diff --git a/bindings/swift/example/LNIExample/LNIExample/ContentView.swift b/bindings/swift/example/LNIExample/LNIExample/ContentView.swift index 0de9990..114e55f 100644 --- a/bindings/swift/example/LNIExample/LNIExample/ContentView.swift +++ b/bindings/swift/example/LNIExample/LNIExample/ContentView.swift @@ -16,11 +16,32 @@ struct ContentView: View { @State private var isLoading: Bool = false @State private var strikeApiKey: String = "" @State private var showApiKey: Bool = false + @State private var use24Words: Bool = false var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { + // Mnemonic Generation Section + GroupBox(label: Label("Wallet Utils", systemImage: "key.fill")) { + VStack(alignment: .leading, spacing: 12) { + Toggle("24 words (default: 12)", isOn: $use24Words) + + Button { + generateNewMnemonic() + } label: { + HStack { + Image(systemName: "sparkles") + Text("Generate Mnemonic") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isLoading) + } + .padding(.vertical, 8) + } + // Strike API Section GroupBox(label: Label("Strike API", systemImage: "bolt.fill")) { VStack(alignment: .leading, spacing: 12) { @@ -150,6 +171,30 @@ struct ContentView: View { isLoading = false } + // MARK: - Generate Mnemonic + + private func generateNewMnemonic() { + output = "=== Generate Mnemonic ===\n\n" + + do { + let wordCount: UInt8? = use24Words ? 24 : nil + let mnemonic = try generateMnemonic(wordCount: wordCount) + + let words = mnemonic.split(separator: " ") + output += "✓ Generated \(words.count)-word mnemonic:\n\n" + + // Display words in a numbered list + for (index, word) in words.enumerated() { + output += String(format: "%2d. %@\n", index + 1, String(word)) + } + + output += "\n⚠️ IMPORTANT: In a real app, never display\n" + output += " the mnemonic on screen. Store it securely!\n" + } catch { + output += "✗ Error: \(error)\n" + } + } + // MARK: - Test Functions private func testStrike() async { diff --git a/bindings/swift/example/LNIExample/lni.swift b/bindings/swift/example/LNIExample/lni.swift index af9f238..0c7092d 100644 --- a/bindings/swift/example/LNIExample/lni.swift +++ b/bindings/swift/example/LNIExample/lni.swift @@ -403,6 +403,22 @@ private let UNIFFI_CALLBACK_SUCCESS: Int32 = 0 private let UNIFFI_CALLBACK_ERROR: Int32 = 1 private let UNIFFI_CALLBACK_UNEXPECTED_ERROR: Int32 = 2 +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterUInt8: FfiConverterPrimitive { + typealias FfiType = UInt8 + typealias SwiftType = UInt8 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt8 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: UInt8, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7249,6 +7265,30 @@ extension InvoiceType: Equatable, Hashable {} +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionUInt8: FfiConverterRustBuffer { + typealias SwiftType = UInt8? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt8.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt8.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -7694,6 +7734,23 @@ public func decode(config: LndConfig, invoiceStr: String)async throws -> String errorHandler: FfiConverterTypeApiError_lift ) } +/** + * Generate a new BIP39 mnemonic phrase for wallet creation. + * Uses cryptographically secure randomness from the OS. + * + * # Arguments + * * `word_count` - Number of words: 12 (default) or 24. If None or invalid, defaults to 12. + * + * # Returns + * A space-separated mnemonic phrase + */ +public func generateMnemonic(wordCount: UInt8?)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeApiError_lift) { + uniffi_lni_fn_func_generate_mnemonic( + FfiConverterOptionUInt8.lower(wordCount),$0 + ) +}) +} public func getInfo(config: LndConfig)async throws -> NodeInfo { return try await uniffiRustCallAsync( @@ -7823,6 +7880,9 @@ private let initializationResult: InitializationResult = { if (uniffi_lni_checksum_func_decode() != 11646) { return InitializationResult.apiChecksumMismatch } + if (uniffi_lni_checksum_func_generate_mnemonic() != 62024) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_lni_checksum_func_get_info() != 59600) { return InitializationResult.apiChecksumMismatch } diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 2d50015..af1b67c 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -43,18 +43,16 @@ impl From for ApiError { /// /// # Returns /// A space-separated mnemonic phrase -#[cfg_attr(feature = "napi_rs", napi)] +#[cfg(not(feature = "napi_rs"))] #[cfg_attr(feature = "uniffi", uniffi::export)] pub fn generate_mnemonic(word_count: Option) -> Result { use bip39::{Language, Mnemonic}; use rand::rngs::OsRng; use rand::RngCore; - // 16 bytes = 128 bits = 12 words - // 32 bytes = 256 bits = 24 words let entropy_size = match word_count { Some(24) => 32, - _ => 16, // Default to 12 words + _ => 16, }; let mut entropy = vec![0u8; entropy_size]; From 3b3f39d25f2e34955512b24c1711925b5977112c Mon Sep 17 00:00:00 2001 From: nicktee Date: Sat, 10 Jan 2026 21:21:40 -0600 Subject: [PATCH 08/19] add generateMnemonic react native --- bindings/lni_react_native/example/src/App.tsx | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/bindings/lni_react_native/example/src/App.tsx b/bindings/lni_react_native/example/src/App.tsx index 1d5a546..d1c3113 100644 --- a/bindings/lni_react_native/example/src/App.tsx +++ b/bindings/lni_react_native/example/src/App.tsx @@ -26,6 +26,7 @@ import { createBlinkNode, createNwcNode, createSpeedNode, + generateMnemonic, } from 'lni_react_native'; import { LND_URL, @@ -383,6 +384,37 @@ export default function App() { } }; + const testMnemonic = async () => { + const nodeName = 'Mnemonic'; + + try { + addOutput(nodeName, 'Testing generateMnemonic...'); + + // Test 12-word mnemonic (default) + addOutput(nodeName, '(1) Generating 12-word mnemonic...'); + const mnemonic12 = generateMnemonic(12); + const words12 = mnemonic12.split(' '); + addOutput(nodeName, `12-word mnemonic: ${mnemonic12}`); + addOutput(nodeName, `Word count: ${words12.length}`); + + // Test 24-word mnemonic + addOutput(nodeName, '(2) Generating 24-word mnemonic...'); + const mnemonic24 = generateMnemonic(24); + const words24 = mnemonic24.split(' '); + addOutput(nodeName, `24-word mnemonic: ${mnemonic24}`); + addOutput(nodeName, `Word count: ${words24.length}`); + + // Verify word counts + if (words12.length === 12 && words24.length === 24) { + updateTestStatus(nodeName, 'success', 'Mnemonic generation tests passed!'); + } else { + updateTestStatus(nodeName, 'error', `Unexpected word counts: 12-word=${words12.length}, 24-word=${words24.length}`); + } + } catch (error) { + updateTestStatus(nodeName, 'error', `Mnemonic test failed: ${error}`); + } + }; + // Initialize test result for an implementation const initializeTest = (implementation: string) => { setTestResults(prev => { @@ -579,6 +611,12 @@ export default function App() { disabled={isRunning} color="#DDA0DD" /> +