diff --git a/index.d.ts b/index.d.ts index 47a9086..2dadcff 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,7 +3,10 @@ /* auto-generated by NAPI-RS */ -export declare function setLogListener(callback?: (...args: any[]) => any | undefined | null, minLevel?: string | undefined | null): void +export declare function setLogListener( + callback?: (...args: any[]) => any | undefined | null, + minLevel?: string | undefined | null, +): void export declare function generateMnemonic(): string export interface MdkNodeOptions { network: string @@ -34,7 +37,7 @@ export interface PaymentEvent { export const enum PaymentEventType { Claimable = 0, Received = 1, - Failed = 2 + Failed = 2, } export interface NodeChannel { channelId: string @@ -68,6 +71,11 @@ export declare class MdkNode { stopReceiving(): void syncWallets(): void getBalance(): number + /** + * Get balance without starting/stopping the node. + * Use this when the node is already running via start_receiving(). + */ + getBalanceWhileRunning(): number listChannels(): Array /** * Manually sync the RGS snapshot. @@ -78,10 +86,61 @@ export declare class MdkNode { syncRgs(doFullSync: boolean): number receivePayment(minThresholdMs: number, quietThresholdMs: number): Array getInvoice(amount: number, description: string, expirySecs: number): PaymentMetadata - getInvoiceWithScid(humanReadableScid: string, amount: number, description: string, expirySecs: number): PaymentMetadata + /** + * Get invoice without starting/stopping the node. + * Use this when the node is already running via start_receiving(). + */ + getInvoiceWhileRunning(amount: number, description: string, expirySecs: number): PaymentMetadata + /** + * Get variable amount invoice without starting/stopping the node. + * Use this when the node is already running via start_receiving(). + */ + getVariableAmountJitInvoiceWhileRunning(description: string, expirySecs: number): PaymentMetadata + getInvoiceWithScid( + humanReadableScid: string, + amount: number, + description: string, + expirySecs: number, + ): PaymentMetadata getVariableAmountJitInvoice(description: string, expirySecs: number): PaymentMetadata - getVariableAmountJitInvoiceWithScid(humanReadableScid: string, description: string, expirySecs: number): PaymentMetadata - payLnurl(lnurl: string, amountMsat: number, waitForPaymentSecs?: number | undefined | null): string - payBolt11(bolt11Invoice: string): string - payBolt12Offer(bolt12OfferString: string, amountMsat: number, waitForPaymentSecs?: number | undefined | null): string + getVariableAmountJitInvoiceWithScid( + humanReadableScid: string, + description: string, + expirySecs: number, + ): PaymentMetadata + /** + * Unified payment method that auto-detects the destination type. + * + * Only supports variable-amount destinations where we set the amount: + * - BOLT12 offers (lno...) + * - LNURL (lnurl...) + * - Lightning addresses (user@domain) + * - Zero-amount BOLT11 invoices + * + * For fixed-amount BOLT11 invoices, amount_msat can be omitted (the invoice amount is used). + * For variable-amount destinations, amount_msat is required. + */ + pay( + destination: string, + amountMsat?: number | undefined | null, + waitForPaymentSecs?: number | undefined | null, + ): string + /** + * Unified payment method that auto-detects the destination type. + * Use this when the node is already running via start_receiving(). + * + * Supports all destination types: + * - BOLT11 invoices (fixed or variable amount) + * - BOLT12 offers (lno...) + * - LNURL (lnurl...) + * - Lightning addresses (user@domain) + * + * For fixed-amount BOLT11 invoices, amount_msat can be omitted (the invoice amount is used). + * For variable-amount destinations, amount_msat is required. + */ + payWhileRunning( + destination: string, + amountMsat?: number | undefined | null, + waitForPaymentSecs?: number | undefined | null, + ): string } diff --git a/src/lib.rs b/src/lib.rs index 6fa52c4..851f554 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,7 @@ use std::{ collections::{HashMap, HashSet}, convert::TryFrom, - fmt::{self, Write}, + fmt::Write, str::FromStr, sync::{ Arc, OnceLock, RwLock, @@ -13,8 +13,8 @@ use std::{ }; use bitcoin_payment_instructions::{ - ParseError, PaymentInstructions, PaymentMethod, amount::Amount as InstructionAmount, - hrn_resolution::HrnResolver, http_resolver::HTTPHrnResolver, + PaymentInstructions, PaymentMethod, amount::Amount as InstructionAmount, + http_resolver::HTTPHrnResolver, }; use napi::{ Env, JsFunction, Status, @@ -33,11 +33,7 @@ use ldk_node::{ config::Config, generate_entropy_mnemonic, lightning::ln::channelmanager::PaymentId, - lightning::{ - ln::msgs::SocketAddress, - offers::offer::{Amount, Offer}, - util::scid_utils, - }, + lightning::{ln::msgs::SocketAddress, offers::offer::Offer, util::scid_utils}, lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, Description}, lightning_types::payment::PaymentHash, }; @@ -52,6 +48,20 @@ const POLL_INTERVAL: Duration = Duration::from_millis(10); /// Max time to wait for channels to become usable after sync. const CHANNEL_USABLE_TIMEOUT: Duration = Duration::from_secs(10); +/// Internal enum representing a resolved payment target with amount +enum PaymentTarget { + Bolt11(Bolt11Invoice, u64), // invoice + amount_msat + Bolt12(Box, u64), // offer + amount_msat +} + +impl PaymentTarget { + fn amount_msat(&self) -> u64 { + match self { + PaymentTarget::Bolt11(_, amount) | PaymentTarget::Bolt12(_, amount) => *amount, + } + } +} + static GLOBAL_LOGGER: OnceLock> = OnceLock::new(); fn logger_instance() -> &'static Arc { @@ -483,6 +493,24 @@ impl MdkNode { panic!("failed to sync wallets: {err}"); } + let balance = self.get_balance_impl(); + + if let Err(err) = self.node.stop() { + eprintln!("[lightning-js] Failed to stop node via stop() in get_balance: {err}"); + panic!("failed to stop node: {err}"); + } + + balance + } + + /// Get balance without starting/stopping the node. + /// Use this when the node is already running via start_receiving(). + #[napi] + pub fn get_balance_while_running(&self) -> i64 { + self.get_balance_impl() + } + + fn get_balance_impl(&self) -> i64 { let total_outbound_msat = self .node .list_channels() @@ -491,11 +519,6 @@ impl MdkNode { acc.saturating_add(channel.outbound_capacity_msat) }); - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node via stop() in get_balance: {err}"); - panic!("failed to stop node: {err}"); - } - u64_to_i64(total_outbound_msat / 1_000) } @@ -690,8 +713,6 @@ impl MdkNode { #[napi] pub fn get_invoice(&self, amount: i64, description: String, expiry_secs: i64) -> PaymentMetadata { - let bolt11_invoice_description = - Bolt11InvoiceDescription::Direct(Description::new(description).unwrap()); if let Err(err) = self.node.start() { eprintln!("[lightning-js] Failed to start node for get_invoice: {err}"); panic!("failed to start node for get_invoice: {err}"); @@ -701,21 +722,58 @@ impl MdkNode { panic!("failed to sync wallets: {err}"); } + let result = self.get_invoice_impl(Some(amount), description, expiry_secs); + + if let Err(err) = self.node.stop() { + eprintln!("[lightning-js] Failed to stop node after get_invoice: {err}"); + } + + result.unwrap() + } + + /// Get invoice without starting/stopping the node. + /// Use this when the node is already running via start_receiving(). + #[napi] + pub fn get_invoice_while_running( + &self, + amount: i64, + description: String, + expiry_secs: i64, + ) -> napi::Result { + self.get_invoice_impl(Some(amount), description, expiry_secs) + } + + /// Get variable amount invoice without starting/stopping the node. + /// Use this when the node is already running via start_receiving(). + #[napi] + pub fn get_variable_amount_jit_invoice_while_running( + &self, + description: String, + expiry_secs: i64, + ) -> napi::Result { + self.get_invoice_impl(None, description, expiry_secs) + } + + fn get_invoice_impl( + &self, + amount: Option, + description: String, + expiry_secs: i64, + ) -> napi::Result { + let bolt11_invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(description).unwrap()); + let invoice = self .node .bolt11_payment() .receive_via_lsps4_jit_channel( - Some(amount as u64), + amount.map(|a| a as u64), &bolt11_invoice_description, expiry_secs as u32, ) - .unwrap(); + .map_err(|e| napi::Error::from_reason(format!("Failed to get invoice: {e}")))?; - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after get_invoice: {err}"); - } - - invoice_to_payment_metadata(invoice) + Ok(invoice_to_payment_metadata(invoice)) } #[napi] @@ -751,15 +809,10 @@ impl MdkNode { description: String, expiry_secs: i64, ) -> PaymentMetadata { - let bolt11_invoice_description = - Bolt11InvoiceDescription::Direct(Description::new(description).unwrap()); - let bolt11 = self - .node - .bolt11_payment() - .receive_via_lsps4_jit_channel(None, &bolt11_invoice_description, expiry_secs as u32) - .unwrap(); - - invoice_to_payment_metadata(bolt11) + // Note: this method doesn't start/stop the node (legacy behavior) + self + .get_invoice_impl(None, description, expiry_secs) + .unwrap() } #[napi] @@ -869,414 +922,307 @@ impl MdkNode { } } + /// Unified payment method that auto-detects the destination type. + /// + /// Only supports variable-amount destinations where we set the amount: + /// - BOLT12 offers (lno...) + /// - LNURL (lnurl...) + /// - Lightning addresses (user@domain) + /// - Zero-amount BOLT11 invoices + /// + /// For fixed-amount BOLT11 invoices, amount_msat can be omitted (the invoice amount is used). + /// For variable-amount destinations, amount_msat is required. #[napi] - pub fn pay_lnurl( + pub fn pay( &self, - lnurl: String, - amount_msat: i64, + destination: String, + amount_msat: Option, wait_for_payment_secs: Option, ) -> napi::Result { eprintln!( - "[lightning-js] pay_lnurl called lnurl={} amount_msat={} wait_for_payment_secs={:?}", - lnurl, amount_msat, wait_for_payment_secs + "[lightning-js] pay called destination={} amount_msat={:?} wait_for_payment_secs={:?}", + destination, amount_msat, wait_for_payment_secs ); - if amount_msat <= 0 { - return Err(napi::Error::new( - Status::InvalidArg, - "amount must be greater than zero".to_string(), - )); - } + let (payment_target, wait_secs) = + self.resolve_payment_target(destination, amount_msat, wait_for_payment_secs)?; - let amount_msat_u64 = u64::try_from(amount_msat).map_err(|_| { - napi::Error::new( - Status::InvalidArg, - "amount must be representable as an unsigned 64-bit value".to_string(), - ) - })?; - - let requested_amount = InstructionAmount::from_milli_sats(amount_msat_u64).map_err(|_| { - napi::Error::new( - Status::InvalidArg, - "amount exceeds supported maximum (21 million BTC)".to_string(), - ) - })?; - - let wait_for_payment_secs = wait_for_payment_secs.unwrap_or(0); - let wait_for_payment_secs = if wait_for_payment_secs > 0 { - Some(wait_for_payment_secs as u64) - } else { - None - }; - eprintln!( - "[lightning-js] pay_lnurl wait_for_payment_secs_normalized={wait_for_payment_secs:?}" - ); - - let resolver = HTTPHrnResolver::new(); - let runtime = create_current_thread_runtime().map_err(lnurl_error_to_napi)?; - eprintln!("[lightning-js] pay_lnurl resolving lnurl invoice"); - let invoice = resolve_lnurl_invoice_with_runtime( - &runtime, - &resolver, - &lnurl, - requested_amount, - self.network, - ) - .map_err(lnurl_error_to_napi)?; - eprintln!( - "[lightning-js] pay_lnurl resolved invoice payment_hash={}", - invoice.payment_hash() - ); - - eprintln!("[lightning-js] pay_lnurl starting node"); - self.node.start().map_err(|error| { - napi::Error::new( - Status::GenericFailure, - format!("failed to start node prior to paying lnurl: {error}"), - ) + // Start node + self.node.start().map_err(|e| { + napi::Error::new(Status::GenericFailure, format!("failed to start node: {e}")) })?; - eprintln!("[lightning-js] pay_lnurl syncing wallets"); - if let Err(err) = self.node.sync_wallets() { - eprintln!("[lightning-js] Failed to sync wallets: {err}"); - panic!("failed to sync wallets: {err}"); - } - - wait_for_usable_channels(&self.node); - let available_balance_msat = usable_outbound_capacity_msat(&self.node); - eprintln!("[lightning-js] pay_lnurl available_balance_msat={available_balance_msat}"); - - if available_balance_msat == 0 { - if let Err(err) = self.node.stop() { - eprintln!( - "[lightning-js] Failed to stop node after checking lnurl outbound capacity: {err}" - ); - } + // Sync wallets + if let Err(e) = self.node.sync_wallets() { + let _ = self.node.stop(); return Err(napi::Error::new( Status::GenericFailure, - "unable to pay lnurl without outbound capacity".to_string(), + format!("failed to sync wallets: {e}"), )); } - if available_balance_msat < amount_msat_u64 { - if let Err(err) = self.node.stop() { - eprintln!( - "[lightning-js] Failed to stop node after insufficient lnurl outbound capacity: {err}" - ); - } - return Err(napi::Error::new( - Status::GenericFailure, - format!( - "insufficient outbound capacity to pay lnurl: required {}msat, available {}msat", - amount_msat_u64, available_balance_msat, - ), - )); - } + let result = self.execute_payment_impl(&payment_target, wait_secs); + let _ = self.node.stop(); + result + } - let payment_id = match self.node.bolt11_payment().send(&invoice, None) { - Ok(payment_id) => payment_id, - Err(error) => { - eprintln!("[lightning-js] pay_lnurl send error: {error}"); - if let Err(stop_error) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after lnurl send error: {stop_error}"); - } - return Err(napi::Error::new( - Status::GenericFailure, - format!("failed to send lnurl payment: {error}"), - )); - } - }; + /// Unified payment method that auto-detects the destination type. + /// Use this when the node is already running via start_receiving(). + /// + /// Supports all destination types: + /// - BOLT11 invoices (fixed or variable amount) + /// - BOLT12 offers (lno...) + /// - LNURL (lnurl...) + /// - Lightning addresses (user@domain) + /// + /// For fixed-amount BOLT11 invoices, amount_msat can be omitted (the invoice amount is used). + /// For variable-amount destinations, amount_msat is required. + #[napi] + pub fn pay_while_running( + &self, + destination: String, + amount_msat: Option, + wait_for_payment_secs: Option, + ) -> napi::Result { eprintln!( - "[lightning-js] pay_lnurl send ok payment_id={}", - bytes_to_hex(&payment_id.0) + "[lightning-js] pay_while_running called destination={} amount_msat={:?} wait_for_payment_secs={:?}", + destination, amount_msat, wait_for_payment_secs ); - if let Some(wait_secs) = wait_for_payment_secs { - eprintln!("[lightning-js] pay_lnurl waiting for payment outcome wait_secs={wait_secs}"); - let wait_result = self.wait_for_payment_outcome(&payment_id, wait_secs); - - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after lnurl payment wait: {err}"); - } - - wait_result?; - } else if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after successful lnurl payment: {err}"); - } + let (payment_target, wait_secs) = + self.resolve_payment_target(destination, amount_msat, wait_for_payment_secs)?; - eprintln!( - "[lightning-js] pay_lnurl returning payment_id={}", - bytes_to_hex(&payment_id.0) - ); - Ok(bytes_to_hex(&payment_id.0)) + self.execute_payment_impl(&payment_target, wait_secs) } - #[napi] - pub fn pay_bolt_11(&self, bolt11_invoice: String) -> napi::Result { - let invoice = Bolt11Invoice::from_str(&bolt11_invoice).map_err(|error| { - napi::Error::new( - Status::InvalidArg, - format!("failed to parse bolt11 invoice: {error}"), - ) - })?; - - let amount_msat = invoice.amount_milli_satoshis().ok_or_else(|| { - napi::Error::new( - Status::InvalidArg, - "bolt11 invoice is missing an amount and cannot be paid".to_string(), - ) - })?; - - self.node.start().map_err(|error| { - napi::Error::new( - Status::GenericFailure, - format!("failed to start node prior to paying bolt11 invoice: {error}"), - ) - })?; - - wait_for_usable_channels(&self.node); - let available_balance_msat = usable_outbound_capacity_msat(&self.node); - eprintln!("[lightning-js] pay_bolt11 available_balance_msat={available_balance_msat}"); + /// Parse destination and resolve to payment target + fn resolve_payment_target( + &self, + destination: String, + amount_msat: Option, + wait_for_payment_secs: Option, + ) -> napi::Result<(PaymentTarget, Option)> { + let wait_secs = wait_for_payment_secs.and_then(|s| if s > 0 { Some(s as u64) } else { None }); - if available_balance_msat == 0 { - if let Err(err) = self.node.stop() { + // Parse destination and resolve to payment method + let resolver = HTTPHrnResolver::new(); + let runtime = create_current_thread_runtime()?; + + eprintln!("[lightning-js] parsing destination"); + let payment_instructions = runtime + .block_on(PaymentInstructions::parse( + &destination, + self.network, + &resolver, + true, + )) + .map_err(|err| { + napi::Error::new( + Status::InvalidArg, + format!("failed to parse destination: {err:?}"), + ) + })?; + + // Get payment methods and amount based on instruction type + let (methods, final_amount_msat): (&[PaymentMethod], u64) = match &payment_instructions { + PaymentInstructions::FixedAmount(fixed) => { + // Use the amount from the invoice + let invoice_amount = fixed + .ln_payment_amount() + .ok_or_else(|| { + napi::Error::new( + Status::InvalidArg, + "fixed-amount destination has no lightning amount", + ) + })? + .milli_sats(); eprintln!( - "[lightning-js] Failed to stop node after checking bolt11 outbound capacity: {err}" + "[lightning-js] fixed-amount destination: {}msat", + invoice_amount ); + (fixed.methods(), invoice_amount) } - return Err(napi::Error::new( - Status::GenericFailure, - "unable to pay bolt11 invoice without outbound capacity".to_string(), - )); - } - - if available_balance_msat < amount_msat { - if let Err(err) = self.node.stop() { - eprintln!( - "[lightning-js] Failed to stop node after insufficient bolt11 outbound capacity: {err}" + PaymentInstructions::ConfigurableAmount(configurable) => { + // Amount is required for configurable-amount destinations + let amount_msat = amount_msat.ok_or_else(|| { + napi::Error::new( + Status::InvalidArg, + "amount_msat is required for variable-amount destinations", + ) + })?; + let amount_msat = u64::try_from(amount_msat) + .ok() + .filter(|&a| a > 0) + .ok_or_else(|| { + napi::Error::new(Status::InvalidArg, "amount_msat must be greater than zero") + })?; + + // Clone and call helper to handle ownership + return self.resolve_configurable_payment( + configurable.clone(), + amount_msat, + wait_secs, + &resolver, + &runtime, ); } - return Err(napi::Error::new( - Status::GenericFailure, - format!( - "insufficient outbound capacity to pay bolt11 invoice: required {}msat, available {}msat", - amount_msat, available_balance_msat, - ), - )); - } - - let payment_id = match self.node.bolt11_payment().send(&invoice, None) { - Ok(payment_id) => payment_id, - Err(error) => { - if let Err(stop_error) = self.node.stop() { - eprintln!( - "[lightning-js] Failed to stop node after bolt11 payment send error: {stop_error}" - ); - } - return Err(napi::Error::new( - Status::GenericFailure, - format!("failed to send bolt11 payment: {error}"), - )); - } }; - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after successful bolt11 payment: {err}"); - } - - Ok(bytes_to_hex(&payment_id.0)) - } - - #[napi] - pub fn pay_bolt12_offer( - &self, - bolt12_offer_string: String, - amount_msat: i64, - wait_for_payment_secs: Option, - ) -> napi::Result { eprintln!( - "[lightning-js] pay_bolt12_offer called amount_msat={} wait_for_payment_secs={:?}", - amount_msat, wait_for_payment_secs + "[lightning-js] resolved {} payment method(s)", + methods.len() ); - eprintln!( - "[lightning-js] pay_bolt12_offer bolt12_offer={}", - bolt12_offer_string - ); - - if amount_msat <= 0 { - return Err(napi::Error::new( - Status::InvalidArg, - "amount must be greater than zero".to_string(), - )); - } - let amount_msat_u64 = u64::try_from(amount_msat).map_err(|_| { - napi::Error::new( - Status::InvalidArg, - "amount must be representable as an unsigned 64-bit value".to_string(), - ) - })?; + let payment_target = methods + .iter() + .find_map(|m| match m { + PaymentMethod::LightningBolt11(inv) => Bolt11Invoice::from_str(&inv.to_string()) + .ok() + .map(|i| PaymentTarget::Bolt11(i, final_amount_msat)), + PaymentMethod::LightningBolt12(offer) => Offer::from_str(&offer.to_string()) + .ok() + .map(|o| PaymentTarget::Bolt12(Box::new(o), final_amount_msat)), + _ => None, + }) + .ok_or_else(|| { + napi::Error::new(Status::GenericFailure, "no supported payment method found") + })?; - let wait_for_payment_secs = wait_for_payment_secs.unwrap_or(0); - let wait_for_payment_secs = if wait_for_payment_secs > 0 { - Some(wait_for_payment_secs as u64) - } else { - None - }; - eprintln!( - "[lightning-js] pay_bolt12_offer wait_for_payment_secs_normalized={wait_for_payment_secs:?}" - ); + Ok((payment_target, wait_secs)) + } - let bolt12_offer = Offer::from_str(&bolt12_offer_string) - .map_err(|_| napi::Error::new(Status::InvalidArg, "invalid bolt12 offer".to_string()))?; + /// Helper for configurable amount payments (handles lifetime issues) + fn resolve_configurable_payment( + &self, + configurable: bitcoin_payment_instructions::ConfigurableAmountPaymentInstructions, + amount_msat: u64, + wait_secs: Option, + resolver: &HTTPHrnResolver, + runtime: &Runtime, + ) -> napi::Result<(PaymentTarget, Option)> { + let requested_amount = InstructionAmount::from_milli_sats(amount_msat) + .map_err(|_| napi::Error::new(Status::InvalidArg, "amount exceeds maximum"))?; + let fixed = runtime + .block_on(configurable.set_amount(requested_amount, resolver)) + .map_err(|err| { + napi::Error::new( + Status::GenericFailure, + format!("failed to set amount: {err}"), + ) + })?; + let methods = fixed.methods(); eprintln!( - "[lightning-js] pay_bolt12_offer parsed offer: id={} issuer_pubkey={:?} description={:?} amount={:?}", - bolt12_offer.id(), - bolt12_offer.issuer_signing_pubkey(), - bolt12_offer.description(), - bolt12_offer.amount() + "[lightning-js] resolved {} payment method(s)", + methods.len() ); - eprintln!("[lightning-js] pay_bolt12_offer starting node"); - self.node.start().map_err(|error| { - napi::Error::new( - Status::GenericFailure, - format!("failed to start node prior to paying offer: {error}"), - ) - })?; - - // Full RGS sync to get node announcements (addresses/features) needed for BOLT12 - eprintln!("[lightning-js] pay_bolt12_offer doing full RGS sync"); - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime for RGS sync"); - rt.block_on(async { - match self.node.sync_rgs(true).await { - Ok(ts) => eprintln!( - "[lightning-js] pay_bolt12_offer RGS sync complete, timestamp={}", - ts - ), - Err(e) => eprintln!("[lightning-js] pay_bolt12_offer RGS sync failed: {}", e), + let payment_target = methods + .iter() + .find_map(|m| match m { + PaymentMethod::LightningBolt11(inv) => Bolt11Invoice::from_str(&inv.to_string()) + .ok() + .map(|i| PaymentTarget::Bolt11(i, amount_msat)), + PaymentMethod::LightningBolt12(offer) => Offer::from_str(&offer.to_string()) + .ok() + .map(|o| PaymentTarget::Bolt12(Box::new(o), amount_msat)), + _ => None, + }) + .ok_or_else(|| { + napi::Error::new(Status::GenericFailure, "no supported payment method found") + })?; + + // Validate BOLT11 invoice amount matches requested (protects against malicious LNURL services) + if let PaymentTarget::Bolt11(ref invoice, _) = payment_target { + if let Some(invoice_amount) = invoice.amount_milli_satoshis() { + if invoice_amount != amount_msat { + return Err(napi::Error::new( + Status::InvalidArg, + format!( + "invoice amount ({invoice_amount}msat) does not match requested amount ({amount_msat}msat)" + ), + )); + } } - }); + } - eprintln!("[lightning-js] pay_bolt12_offer syncing wallets"); - if let Err(err) = self.node.sync_wallets() { - eprintln!("[lightning-js] Failed to sync wallets: {err}"); - panic!("failed to sync wallets: {err}"); + Ok((payment_target, wait_secs)) + } + + /// Core payment execution logic (no start/stop) + fn execute_payment_impl( + &self, + target: &PaymentTarget, + wait_secs: Option, + ) -> napi::Result { + // BOLT12 requires full RGS sync for onion message routing + if matches!(target, PaymentTarget::Bolt12(_, _)) { + eprintln!("[lightning-js] doing full RGS sync for BOLT12"); + let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + rt.block_on(async { + match self.node.sync_rgs(true).await { + Ok(ts) => eprintln!("[lightning-js] RGS sync complete, timestamp={ts}"), + Err(e) => eprintln!("[lightning-js] RGS sync failed: {e}"), + } + }); } - eprintln!("[lightning-js] pay_bolt12_offer wallet sync complete"); + // Wait for channels and check balance wait_for_usable_channels(&self.node); - let available_balance_msat = usable_outbound_capacity_msat(&self.node); + let available = usable_outbound_capacity_msat(&self.node); + eprintln!("[lightning-js] available outbound capacity: {available}msat"); - eprintln!( - "[lightning-js] pay_bolt12_offer available_balance_msat={}", - available_balance_msat - ); - - if available_balance_msat == 0 { - if let Err(err) = self.node.stop() { - eprintln!( - "[lightning-js] Failed to stop node after checking bolt12 outbound capacity: {err}" - ); - } + let required = target.amount_msat(); + if available < required { return Err(napi::Error::new( Status::GenericFailure, - "unable to pay bolt12 offer without outbound capacity".to_string(), + format!( + "insufficient outbound capacity: required {required}msat, available {available}msat" + ), )); } - let amount_to_send_msat = match bolt12_offer.amount() { - Some(Amount::Bitcoin { amount_msats }) => amount_msats, - Some(_) => { - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after unsupported bolt12 currency: {err}"); - } - return Err(napi::Error::new( - Status::GenericFailure, - "unsupported currency in bolt12 offer".to_string(), - )); + // Send payment + let payment_id = match target { + PaymentTarget::Bolt11(invoice, amount) => { + eprintln!( + "[lightning-js] sending BOLT11 payment_hash={} amount={}msat", + invoice.payment_hash(), + amount + ); + self + .node + .bolt11_payment() + .send_using_amount(invoice, *amount, None) } - None => amount_msat_u64, - }; - - eprintln!( - "[lightning-js] pay_bolt12_offer amount_to_send_msat={}", - amount_to_send_msat - ); - - if amount_to_send_msat == 0 { - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after zero-amount bolt12 offer: {err}"); + PaymentTarget::Bolt12(offer, amount) => { + eprintln!("[lightning-js] sending BOLT12 offer_id={}", offer.id()); + self.node.bolt12_payment().send_using_amount( + offer, + *amount, + None, + Some("A payment by MoneyDevKit".to_string()), + None, + ) } - return Err(napi::Error::new( - Status::GenericFailure, - "bolt12 offer amount resolves to zero".to_string(), - )); } - - if available_balance_msat < amount_to_send_msat { - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after insufficient outbound capacity: {err}"); - } - return Err(napi::Error::new( + .map_err(|e| { + napi::Error::new( Status::GenericFailure, - format!( - "insufficient outbound capacity to pay offer: required {}msat, available {}msat", - amount_to_send_msat, available_balance_msat, - ), - )); - } - - eprintln!( - "[lightning-js] pay_bolt12_offer sending payment: offer_id={} amount_msat={}", - bolt12_offer.id(), - amount_to_send_msat - ); + format!("failed to send payment: {e}"), + ) + })?; - let payment_id = match self.node.bolt12_payment().send_using_amount( - &bolt12_offer, - amount_to_send_msat, - None, - Some("A payment by MoneyDevKit".to_string()), - None, - ) { - Ok(payment_id) => payment_id, - Err(error) => { - eprintln!("[lightning-js] pay_bolt12_offer send error: {error}"); - if let Err(stop_error) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after bolt12 send error: {stop_error}"); - } - return Err(napi::Error::new( - Status::GenericFailure, - format!("failed to send bolt12 offer payment: {error}"), - )); - } - }; eprintln!( - "[lightning-js] pay_bolt12_offer send ok payment_id={}", + "[lightning-js] payment sent, id={}", bytes_to_hex(&payment_id.0) ); - if let Some(wait_secs) = wait_for_payment_secs { - eprintln!( - "[lightning-js] pay_bolt12_offer waiting for payment outcome wait_secs={wait_secs}" - ); - let wait_result = self.wait_for_payment_outcome(&payment_id, wait_secs); - - if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after bolt12 payment wait: {err}"); - } - - wait_result?; - } else if let Err(err) = self.node.stop() { - eprintln!("[lightning-js] Failed to stop node after successful bolt12 payment: {err}"); + // Wait for outcome if requested + if let Some(secs) = wait_secs { + self.wait_for_payment_outcome(&payment_id, secs)?; } - eprintln!( - "[lightning-js] pay_bolt12_offer returning payment_id={}", - bytes_to_hex(&payment_id.0) - ); Ok(bytes_to_hex(&payment_id.0)) } } @@ -1365,99 +1311,14 @@ fn u64_to_i64(value: u64) -> i64 { i64::try_from(value).unwrap_or(i64::MAX) } -#[derive(Debug)] -enum LnurlPayError { - RuntimeInit(String), - Parse(ParseError), - Finalization(&'static str), - MissingInvoice, - InvoiceParse(String), - AmountMismatch { - invoice_msat: u64, - requested_msat: u64, - }, -} - -impl fmt::Display for LnurlPayError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::RuntimeInit(err) => write!(f, "failed to initialize async runtime for lnurl: {err}"), - Self::Parse(err) => write!(f, "failed to parse lnurl instructions: {err:?}"), - Self::Finalization(err) => write!(f, "failed to finalize lnurl amount: {err}"), - Self::MissingInvoice => write!( - f, - "payment instructions did not resolve to a lightning invoice" - ), - Self::InvoiceParse(err) => write!(f, "failed to parse resolved lightning invoice: {err}"), - Self::AmountMismatch { - invoice_msat, - requested_msat, - } => write!( - f, - "invoice amount ({invoice_msat}msat) does not match requested amount ({requested_msat}msat)" - ), - } - } -} - -impl std::error::Error for LnurlPayError {} - -fn lnurl_error_to_napi(err: LnurlPayError) -> napi::Error { - let status = match err { - LnurlPayError::Parse(_) | LnurlPayError::Finalization(_) => Status::InvalidArg, - _ => Status::GenericFailure, - }; - napi::Error::new(status, err.to_string()) -} - -fn create_current_thread_runtime() -> Result { +fn create_current_thread_runtime() -> Result { tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .map_err(|error| LnurlPayError::RuntimeInit(error.to_string())) -} - -fn resolve_lnurl_invoice_with_runtime( - runtime: &Runtime, - resolver: &R, - lnurl: &str, - requested_amount: InstructionAmount, - network: Network, -) -> Result { - let payment_instructions = runtime - .block_on(PaymentInstructions::parse(lnurl, network, resolver, true)) - .map_err(LnurlPayError::Parse)?; - - let fixed_instructions = match payment_instructions { - PaymentInstructions::FixedAmount(fixed) => fixed, - PaymentInstructions::ConfigurableAmount(configurable) => runtime - .block_on(configurable.set_amount(requested_amount, resolver)) - .map_err(LnurlPayError::Finalization)?, - }; - - let invoice_string = fixed_instructions - .methods() - .iter() - .find_map(|method| match method { - PaymentMethod::LightningBolt11(invoice) => Some(invoice.to_string()), - _ => None, + .map_err(|error| { + napi::Error::new( + Status::GenericFailure, + format!("failed to initialize async runtime: {error}"), + ) }) - .ok_or(LnurlPayError::MissingInvoice)?; - - let invoice = Bolt11Invoice::from_str(&invoice_string) - .map_err(|error| LnurlPayError::InvoiceParse(error.to_string()))?; - - let invoice_amount_msat = invoice - .amount_milli_satoshis() - .ok_or(LnurlPayError::MissingInvoice)?; - let requested_msat = requested_amount.milli_sats(); - - if invoice_amount_msat != requested_msat { - return Err(LnurlPayError::AmountMismatch { - invoice_msat: invoice_amount_msat, - requested_msat, - }); - } - - Ok(invoice) }