From f82544951480495e82b605cffea05cb0a28979a6 Mon Sep 17 00:00:00 2001 From: amackillop Date: Tue, 13 Jan 2026 13:14:15 -0800 Subject: [PATCH] Add receive_payment_with_callback for instant confirmations Implement a callback-based payment receive method that fires a JavaScript callback immediately when each payment is received, rather than waiting for the full timeout before returning. Key changes: - Add receive_payment_with_callback() in Rust with ThreadsafeFunction - Add ReceivedPayment struct with Clone derive for callback use - Update TypeScript definitions for the new method Testing revealed that while callbacks fire correctly, the RPC calls are deferred until the Rust function returns due to Node.js event loop blocking. The PLAN.md has been updated with: - Documentation of this architectural limitation - Solution 3: Async Rust implementation for truly instant callbacks - Implementation roadmap for future work Current behavior: Callbacks queue during event loop, execute after return. This still improves UX by enabling per-payment notifications rather than batching, and sets the foundation for the async fix. --- PLAN.md | 752 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.d.ts | 26 +- src/lib.rs | 130 +++++++++ 3 files changed, 893 insertions(+), 15 deletions(-) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..658493b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,752 @@ +# Payment Receive Design Improvements + +## Problem Statement + +The current `receive_payment()` function has a fundamental design flaw: + +1. **Blocking**: It blocks for `min_threshold_ms + quiet_threshold_ms` (~15-20s) before returning +2. **Delayed Confirmation**: Even if a payment arrives in second 1, the customer doesn't see confirmation until the function returns +3. **Reliability vs UX tradeoff**: Longer timeouts improve reliability (channel opens succeed) but hurt UX (slow confirmations) + +### Current Flow + +``` +Webhook arrives → node.receivePayments() BLOCKS 15+ seconds → Returns payments → Customer sees confirmation + ↑ + Payment arrives here (second 3) but customer waits until second 15+ +``` + +--- + +## Solution 1: Disable Anchor Channels (Already Implemented) + +**Status**: ✅ Implemented in `lightning-js/src/lib.rs` lines 271-279 + +This addresses reliability by ensuring LSP opens non-anchor channels directly, reducing the time needed for channel negotiation. + +--- + +## Solution 2: Immediate Payment Callbacks + +Two implementation options for immediate customer confirmation: + +### Option A: Callback-Based (Simpler) + +Pass a JavaScript callback to `receive_payment` that fires immediately when a payment arrives. + +### Option B: Event Emitter Pattern (More Flexible) + +Register event listeners before starting the receive loop, allowing multiple subscribers. + +--- + +# Option A: Callback-Based Implementation + +## Overview + +Add a new `receive_payment_with_callback()` function that accepts a callback which fires immediately when payments are received, while still keeping the node alive for the full timeout period. + +## Changes Required + +### 1. lightning-js/src/lib.rs + +#### Add new struct for callback data + +```rust +#[napi(object)] +pub struct PaymentCallbackData { + pub payment_hash: String, + pub amount_msat: i64, +} +``` + +#### Add new method + +```rust +#[napi] +pub fn receive_payment_with_callback( + &self, + env: Env, + min_threshold_ms: i64, + quiet_threshold_ms: i64, + on_payment_received: JsFunction, +) -> napi::Result> { + // Create threadsafe function for callback + let tsfn: ThreadsafeFunction = on_payment_received + .create_threadsafe_function(0, |ctx| { + let env = ctx.env; + let data = ctx.value; + let mut obj = env.create_object()?; + obj.set_named_property("paymentHash", env.create_string(&data.payment_hash)?)?; + obj.set_named_property("amountMsat", env.create_int64(data.amount_msat)?)?; + Ok(vec![obj.into_unknown()]) + })?; + + let mut received_payments = vec![]; + + if let Err(err) = self.node.start() { + eprintln!("[lightning-js] Failed to start node: {err}"); + return Ok(received_payments); + } + + if let Err(err) = self.node.sync_wallets() { + eprintln!("[lightning-js] Failed to sync wallets: {err}"); + panic!("failed to sync wallets: {err}"); + } + + let start_sync_at = std::time::Instant::now(); + let mut last_event_time = start_sync_at; + + loop { + let now = std::time::Instant::now(); + let total_time_elapsed = now.duration_since(start_sync_at).as_millis() as i64; + let quiet_time_elapsed = now.duration_since(last_event_time).as_millis() as i64; + + if total_time_elapsed >= min_threshold_ms && quiet_time_elapsed >= quiet_threshold_ms { + break; + } + + if let Some(event) = self.node.next_event() { + eprintln!("[lightning-js] Event: {event:?}"); + + if let Event::PaymentReceived { + payment_hash, + amount_msat, + .. + } = &event + { + let payment_hash_hex = bytes_to_hex(&payment_hash.0); + + // Fire callback IMMEDIATELY + let callback_data = PaymentCallbackData { + payment_hash: payment_hash_hex.clone(), + amount_msat: *amount_msat as i64, + }; + tsfn.call(Ok(callback_data), ThreadsafeFunctionCallMode::Blocking); + + received_payments.push(ReceivedPayment { + payment_hash: payment_hash.to_string(), + amount: *amount_msat as i64, + }); + } + + // Handle other events (PaymentClaimable, PaymentFailed, etc.) with logging + // ... + + if let Err(err) = self.node.event_handled() { + eprintln!("[lightning-js] Error while marking event handled: {err}"); + } + last_event_time = now; + } + + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + if let Err(err) = self.node.stop() { + eprintln!("[lightning-js] Failed to stop node: {err}"); + } + + Ok(received_payments) +} +``` + +### 2. mdk-checkout/packages/core/src/lightning-node.ts + +#### Update MoneyDevKitNode class + +```typescript +// Add new method alongside existing receivePayments() +receivePaymentsWithCallback(onPaymentReceived: (payment: { paymentHash: string; amountMsat: number }) => void) { + return this.node.receivePaymentWithCallback( + RECEIVE_PAYMENTS_MIN_THRESHOLD_MS, + RECEIVE_PAYMENTS_QUIET_THRESHOLD_MS, + (err: unknown, payment: { paymentHash: string; amountMsat: number }) => { + if (err) { + console.error('[MoneyDevKitNode] Payment callback error:', err); + return; + } + onPaymentReceived(payment); + } + ); +} +``` + +### 3. mdk-checkout/packages/core/src/handlers/webhooks.ts + +#### Update webhook handler + +```typescript +async function handleIncomingPayment() { + const node = createMoneyDevKitNode(); + const client = createMoneyDevKitClient(); + + // Payments are processed IMMEDIATELY as they arrive + node.receivePaymentsWithCallback(async (payment) => { + // Customer sees confirmation NOW + markPaymentReceived(payment.paymentHash); + + try { + await client.checkouts.paymentReceived({ + payments: [{ + paymentHash: payment.paymentHash, + amountSats: payment.amountMsat / 1000, + sandbox: false, + }], + }); + } catch (error) { + warn("Failed to notify MoneyDevKit checkout", error); + } + }); + + // Function still returns after timeouts for cleanup +} +``` + +## Pros + +- Simple implementation using existing `ThreadsafeFunction` pattern (same as logging) +- Minimal changes to existing code structure +- Backward compatible (old `receive_payment` still works) +- Clear control flow + +## Cons + +- Slightly more verbose API +- Callback can't easily signal "stop early" + +## Complexity + +- **Rust changes**: ~50 lines of new code +- **TypeScript changes**: ~20 lines of new code +- **Risk**: Low - uses proven patterns already in codebase + +--- + +# Option B: Event Emitter Pattern + +## Overview + +Create a stateful event listener system where callbacks are registered before starting the receive loop. Events fire to all registered listeners immediately. + +## Changes Required + +### 1. lightning-js/src/lib.rs + +#### Add event listener storage to MdkNode + +```rust +use std::sync::Mutex; + +#[napi] +pub struct MdkNode { + node: Node, + network: Network, + payment_listeners: Arc>>>, +} + +impl MdkNode { + // ... existing constructor updated to initialize payment_listeners + + #[napi] + pub fn on_payment_received(&self, env: Env, callback: JsFunction) -> napi::Result<()> { + let tsfn: ThreadsafeFunction = callback + .create_threadsafe_function(0, |ctx| { + let env = ctx.env; + let data = ctx.value; + let mut obj = env.create_object()?; + obj.set_named_property("paymentHash", env.create_string(&data.payment_hash)?)?; + obj.set_named_property("amountMsat", env.create_int64(data.amount_msat)?)?; + Ok(vec![obj.into_unknown()]) + })?; + + let mut listeners = self.payment_listeners.lock().unwrap(); + listeners.push(tsfn); + Ok(()) + } + + #[napi] + pub fn remove_all_payment_listeners(&self) { + let mut listeners = self.payment_listeners.lock().unwrap(); + listeners.clear(); + } + + // Helper to fire events to all listeners + fn emit_payment_received(&self, payment_hash: String, amount_msat: i64) { + let listeners = self.payment_listeners.lock().unwrap(); + let data = PaymentCallbackData { payment_hash, amount_msat }; + + for tsfn in listeners.iter() { + tsfn.call(Ok(data.clone()), ThreadsafeFunctionCallMode::NonBlocking); + } + } + + // Updated receive_payment that uses listeners + #[napi] + pub fn wait_for_events( + &self, + min_threshold_ms: i64, + quiet_threshold_ms: i64, + ) -> Vec { + // ... same loop as receive_payment but calls emit_payment_received() + // instead of just collecting payments + } +} +``` + +### 2. mdk-checkout/packages/core/src/lightning-node.ts + +#### Add event emitter wrapper + +```typescript +export class MoneyDevKitNode { + private node: LightningNodeInstance; + private paymentHandlers: Set<(payment: ReceivedPayment) => void> = new Set(); + + onPaymentReceived(handler: (payment: ReceivedPayment) => void): () => void { + this.paymentHandlers.add(handler); + + // Register with native node + this.node.onPaymentReceived((err: unknown, payment: ReceivedPayment) => { + if (err) { + console.error('[MoneyDevKitNode] Payment event error:', err); + return; + } + handler(payment); + }); + + // Return unsubscribe function + return () => { + this.paymentHandlers.delete(handler); + }; + } + + waitForEvents() { + return this.node.waitForEvents( + RECEIVE_PAYMENTS_MIN_THRESHOLD_MS, + RECEIVE_PAYMENTS_QUIET_THRESHOLD_MS, + ); + } +} +``` + +### 3. mdk-checkout/packages/core/src/handlers/webhooks.ts + +#### Update webhook handler + +```typescript +async function handleIncomingPayment() { + const node = createMoneyDevKitNode(); + const client = createMoneyDevKitClient(); + + // Register listener BEFORE waiting + node.onPaymentReceived(async (payment) => { + // Customer sees confirmation IMMEDIATELY + markPaymentReceived(payment.paymentHash); + + try { + await client.checkouts.paymentReceived({ + payments: [{ + paymentHash: payment.paymentHash, + amountSats: payment.amount / 1000, + sandbox: false, + }], + }); + } catch (error) { + warn("Failed to notify MoneyDevKit checkout", error); + } + }); + + // Start waiting for events (fires callbacks as they arrive) + node.waitForEvents(); +} +``` + +## Pros + +- Familiar event emitter pattern for JS developers +- Multiple listeners supported +- Can add other event types easily (PaymentFailed, ChannelOpened, etc.) +- More extensible for future use cases + +## Cons + +- More complex implementation +- Requires managing listener lifecycle +- State management across start/stop cycles is tricky + +## Complexity + +- **Rust changes**: ~100 lines of new/modified code +- **TypeScript changes**: ~40 lines of new code +- **Risk**: Medium - new patterns, state management concerns + +--- + +# Comparison + +| Aspect | Option A (Callback) | Option B (Event Emitter) | +|--------|---------------------|-------------------------| +| **Complexity** | Low | Medium | +| **Lines of code** | ~70 | ~140 | +| **Learning curve** | Minimal | Low | +| **Extensibility** | Limited | High | +| **Multiple subscribers** | No | Yes | +| **Risk** | Low | Medium | +| **Time to implement** | 2-4 hours | 4-8 hours | + +--- + +# Recommendation + +**Start with Option A (Callback-Based)** because: + +1. Uses the exact same `ThreadsafeFunction` pattern already working for logging +2. Solves the immediate problem with minimal changes +3. Lower risk for a staging-blocking issue +4. Can migrate to Option B later if extensibility is needed + +## Implementation Order + +1. ✅ Disable anchor channels (already done) +2. Implement Option A callback in `lightning-js` +3. Update `mdk-checkout` to use callback +4. Test in staging with reduced timeouts: + - `min_threshold_ms`: 5000 (can be shorter now) + - `quiet_threshold_ms`: 2000 (payment confirmed immediately anyway) +5. Measure customer confirmation latency improvement + +## Expected Outcome + +| Metric | Before | After (Option A) | +|--------|--------|------------------| +| **Payment confirmation** | 15-20s | <1s after payment | +| **Node alive time** | 15-20s | 5-7s | +| **Reliability** | ✅ Works | ✅ Works | +| **Customer UX** | ❌ Slow | ✅ Fast | + +--- + +# Implementation Status & Learnings + +## Option A: Implemented ✅ + +The callback-based approach was implemented as described above: +- `receive_payment_with_callback()` added to `lightning-js/src/lib.rs` +- `receivePaymentsWithCallback()` added to `mdk-checkout` +- Webhook handler updated to use callbacks + +## Discovered Limitation: Node.js Event Loop Blocking + +**The callbacks fire, but RPC execution is deferred until after Rust returns.** + +### Root Cause + +When Rust calls a JavaScript callback via `ThreadsafeFunction`: +1. Node.js is **single-threaded** +2. While Rust runs synchronously, the Node.js event loop is **blocked** +3. Callbacks return immediately (queuing Promises) +4. Queued Promises only execute **after Rust returns** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Node.js Event Loop │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ Rust receive_payment_with_callback() [BLOCKING] ││ +│ │ ├── Start node ││ +│ │ ├── Event loop (15s) ││ +│ │ │ ├── PaymentReceived → callback fires ││ +│ │ │ │ └── RPC Promise QUEUED ← Can't execute! ││ +│ │ │ ├── PaymentReceived → callback fires ││ +│ │ │ │ └── RPC Promise QUEUED ← Can't execute! ││ +│ │ │ └── ... continues ... ││ +│ │ └── Return ││ +│ └─────────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ NOW: Queued Promises finally execute ││ +│ │ └── Customer sees confirmation (15s+ after payment) ││ +│ └─────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +### Observed Behavior + +From testing: +- Callbacks ARE firing (4 individual RPC calls observed) +- Each payment gets its own RPC (not batched) ✅ +- But ALL RPC calls execute AFTER `receive_payment_with_callback()` returns ❌ +- Customer waits full `min_threshold_ms` before seeing confirmation + +### Current Workaround + +Reduce timeouts to minimize wait time: +```typescript +const RECEIVE_PAYMENTS_MIN_THRESHOLD_MS = 5000 // Was 15000 +const RECEIVE_PAYMENTS_QUIET_THRESHOLD_MS = 2000 // Was 5000 +``` + +This reduces customer wait from ~15s to ~5s, but doesn't solve the fundamental issue. + +--- + +# Solution 3: Async Rust with Promise Awaiting (Proper Fix) + +## Overview + +Make `receive_payment_with_callback` an **async function** that properly awaits JavaScript Promises, allowing Node.js event loop to run between events. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Node.js Event Loop │ +│ │ +│ async receive_payment_with_callback() │ +│ ├── Start node │ +│ ├── Loop: │ +│ │ ├── await next_event() ← Node.js can run! │ +│ │ ├── PaymentReceived │ +│ │ │ └── await callback() ← RPC executes NOW! │ +│ │ │ └── Customer sees confirmation │ +│ │ └── continue loop... │ +│ └── Return │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Changes Required + +### 1. Cargo.toml - Enable async features + +```toml +[dependencies] +napi = { version = "2", features = ["napi4", "async"] } +tokio = { version = "1", features = ["rt-multi-thread", "sync"] } +``` + +### 2. lightning-js/src/lib.rs - Async implementation + +```rust +use napi::bindgen_prelude::*; +use tokio::sync::mpsc; + +/// Async version that properly awaits JavaScript Promise callbacks +#[napi] +pub async fn receive_payment_with_callback_async( + &self, + min_threshold_ms: i64, + quiet_threshold_ms: i64, + on_payment_received: JsFunction, +) -> napi::Result> { + // Create a channel for async event processing + let (tx, mut rx) = mpsc::channel::(32); + + // Spawn event polling in background + let node = self.node.clone(); + let poll_handle = tokio::spawn(async move { + loop { + if let Some(event) = node.next_event() { + if tx.send(event).await.is_err() { + break; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + + let mut received_payments = vec![]; + let start = Instant::now(); + let mut last_event = start; + + // Main event loop - async, allows Node.js to run + loop { + let now = Instant::now(); + let total = now.duration_since(start).as_millis() as i64; + let quiet = now.duration_since(last_event).as_millis() as i64; + + if total >= min_threshold_ms && quiet >= quiet_threshold_ms { + break; + } + + // Non-blocking receive with timeout + match tokio::time::timeout( + Duration::from_millis(100), + rx.recv() + ).await { + Ok(Some(event)) => { + if let Event::PaymentReceived { payment_hash, amount_msat, .. } = &event { + let payment = ReceivedPayment { + payment_hash: bytes_to_hex(&payment_hash.0), + amount: *amount_msat as i64, + }; + + // Call JS callback and AWAIT the Promise + // This yields to Node.js event loop, allowing RPC to execute + let promise: Promise<()> = on_payment_received.call(None, &[payment.clone()])?; + promise.await?; // ← Node.js runs here, RPC completes! + + received_payments.push(payment); + } + last_event = now; + } + _ => continue, + } + } + + poll_handle.abort(); + Ok(received_payments) +} +``` + +### 3. Alternative: Use call_async from ThreadsafeFunction + +NAPI-RS 2.x supports `call_async` which returns a future: + +```rust +use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; + +#[napi] +pub async fn receive_payment_with_callback_async( + &self, + min_threshold_ms: i64, + quiet_threshold_ms: i64, + on_payment_received: JsFunction, +) -> napi::Result> { + // Create threadsafe function that returns Promise + let tsfn: ThreadsafeFunction> = on_payment_received + .create_threadsafe_function(0, |ctx| { + // ... convert to JS object + })?; + + // ... event loop ... + + if let Event::PaymentReceived { payment_hash, amount_msat, .. } = &event { + let payment = ReceivedPayment { /* ... */ }; + + // call_async returns a Future that resolves when JS Promise resolves + tsfn.call_async(Ok(payment.clone())).await?; // ← Awaits Promise! + + received_payments.push(payment); + } + + // ... +} +``` + +### 4. mdk-checkout TypeScript changes + +```typescript +// lightning-node.ts +async receivePaymentsWithCallback( + onPaymentReceived: (payment: ReceivedPayment) => Promise +): Promise { + // Now the callback MUST return a Promise that resolves when done + return await this.node.receivePaymentWithCallbackAsync( + RECEIVE_PAYMENTS_MIN_THRESHOLD_MS, + RECEIVE_PAYMENTS_QUIET_THRESHOLD_MS, + async (payment: ReceivedPayment) => { + await onPaymentReceived(payment); + } + ); +} + +// webhooks.ts +async function handleIncomingPayment() { + const node = createMoneyDevKitNode(); + const client = createMoneyDevKitClient(); + + await node.receivePaymentsWithCallback(async (payment) => { + markPaymentReceived(payment.paymentHash); + + // This MUST complete before Rust continues + await client.checkouts.paymentReceived({ + payments: [{ + paymentHash: payment.paymentHash, + amountSats: payment.amount / 1000, + sandbox: false, + }], + }); + // Customer sees confirmation NOW, not after timeout! + }); +} +``` + +## Complexity Assessment + +| Aspect | Estimate | +|--------|----------| +| **Rust changes** | ~150 lines (new async function + refactoring) | +| **TypeScript changes** | ~30 lines (update to async/await) | +| **NAPI-RS learning curve** | Medium (async patterns) | +| **Testing complexity** | Medium (async timing, error handling) | +| **Risk** | Medium (new async patterns, but well-documented) | +| **Time to implement** | 1-2 days | + +## Key Considerations + +### 1. Error Handling +If the JS callback Promise rejects, Rust needs to handle it gracefully: +```rust +match tsfn.call_async(Ok(payment.clone())).await { + Ok(_) => { /* Success */ } + Err(e) => { + eprintln!("[lightning-js] Callback failed: {e}"); + // Continue processing other events + } +} +``` + +### 2. Callback Timeout +Add a timeout to prevent hanging on slow callbacks: +```rust +match tokio::time::timeout( + Duration::from_secs(5), + tsfn.call_async(Ok(payment.clone())) +).await { + Ok(Ok(_)) => { /* Success */ } + Ok(Err(e)) => { /* JS error */ } + Err(_) => { /* Timeout */ } +} +``` + +### 3. Backpressure +If many payments arrive quickly, the callback might not keep up. Consider: +- Buffering payments and processing in batches +- Parallel callback execution with `tokio::spawn` +- Configurable concurrency limits + +### 4. Graceful Shutdown +Ensure in-flight callbacks complete before returning: +```rust +// Wait for any pending callbacks before shutdown +while let Some(_) = pending_callbacks.next().await {} +``` + +## Expected Outcome + +| Metric | Current (Option A) | After (Solution 3) | +|--------|-------------------|-------------------| +| **Payment confirmation** | 5-15s (after timeout) | <500ms (immediate) | +| **RPC timing** | Batched at end | During event loop | +| **Node.js event loop** | Blocked | Free to run | +| **Customer UX** | ❌ Still slow | ✅ Truly instant | + +--- + +# Implementation Roadmap + +1. **Phase 1: Workaround (Done)** + - ✅ Implement Option A callback + - ✅ Reduce timeouts (5s/2s) + - Result: 15s → 5s confirmation time + +2. **Phase 2: Proper Fix (Solution 3)** + - [ ] Add `async` feature to napi in Cargo.toml + - [ ] Implement `receive_payment_with_callback_async` + - [ ] Update mdk-checkout to use async version + - [ ] Test with realistic payment scenarios + - Result: 5s → <500ms confirmation time + +3. **Phase 3: Optimization (Future)** + - [ ] Add parallel callback execution + - [ ] Implement backpressure handling + - [ ] Add metrics/tracing for callback latency + - Result: Handle high-volume concurrent payments diff --git a/index.d.ts b/index.d.ts index e879dd8..4b83401 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,10 +3,7 @@ /* 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 @@ -54,19 +51,18 @@ export declare class MdkNode { */ syncRgs(doFullSync: boolean): number receivePayment(minThresholdMs: number, quietThresholdMs: number): Array + /** + * Receive payments with an immediate callback for each payment received. + * + * This method behaves like `receive_payment` but fires a callback immediately + * when each payment is received, allowing for instant customer confirmation + * while the node continues to run for the full timeout period. + */ + receivePaymentWithCallback(minThresholdMs: number, quietThresholdMs: number, onPaymentReceived: (...args: any[]) => any): Array getInvoice(amount: number, description: string, expirySecs: number): PaymentMetadata - getInvoiceWithScid( - humanReadableScid: string, - amount: number, - 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 + 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 diff --git a/src/lib.rs b/src/lib.rs index 2038519..918528e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -227,6 +227,7 @@ pub struct PaymentMetadata { pub scid: String, } +#[derive(Clone)] #[napi(object)] pub struct ReceivedPayment { pub payment_hash: String, @@ -547,6 +548,135 @@ impl MdkNode { received_payments } + /// Receive payments with an immediate callback for each payment received. + /// + /// This method behaves like `receive_payment` but fires a callback immediately + /// when each payment is received, allowing for instant customer confirmation + /// while the node continues to run for the full timeout period. + #[napi] + pub fn receive_payment_with_callback( + &self, + _env: Env, + min_threshold_ms: i64, + quiet_threshold_ms: i64, + on_payment_received: JsFunction, + ) -> napi::Result> { + // Create threadsafe function for callback + let tsfn: ThreadsafeFunction = on_payment_received + .create_threadsafe_function(0, |ctx| { + let env = ctx.env; + let data: ReceivedPayment = ctx.value; + let mut obj = env.create_object()?; + obj.set_named_property("paymentHash", env.create_string(&data.payment_hash)?)?; + obj.set_named_property("amount", env.create_int64(data.amount)?)?; + Ok(vec![obj.into_unknown()]) + })?; + + let mut received_payments = vec![]; + + if let Err(err) = self.node.start() { + eprintln!("[lightning-js] Failed to start node in receive_payment_with_callback: {err}"); + return Ok(received_payments); + } + + if let Err(err) = self.node.sync_wallets() { + eprintln!("[lightning-js] Failed to sync wallets: {err}"); + panic!("failed to sync wallets: {err}"); + } + + let start_sync_at = std::time::Instant::now(); + let mut last_event_time = start_sync_at; + + loop { + let now = std::time::Instant::now(); + + let total_time_elapsed = now.duration_since(start_sync_at).as_millis() as i64; + let quiet_time_elapsed = now.duration_since(last_event_time).as_millis() as i64; + + if total_time_elapsed >= min_threshold_ms && quiet_time_elapsed >= quiet_threshold_ms { + break; + } + + if let Some(event) = self.node.next_event() { + eprintln!("[lightning-js] Event: {event:?}"); + + match &event { + Event::PaymentFailed { + payment_id, + payment_hash, + reason, + } => { + let payment_id_hex = payment_id + .as_ref() + .map(|id| bytes_to_hex(&id.0)) + .unwrap_or_else(|| "None".to_string()); + let payment_hash_hex = payment_hash + .as_ref() + .map(|hash| bytes_to_hex(&hash.0)) + .unwrap_or_else(|| "None".to_string()); + let reason_str = reason + .as_ref() + .map(|r| format!("{r:?}")) + .unwrap_or_else(|| "Unknown".to_string()); + + eprintln!( + "[lightning-js] PaymentFailed payment_id={payment_id_hex} payment_hash={payment_hash_hex} reason={reason_str}", + ); + } + Event::PaymentClaimable { + payment_hash, + claimable_amount_msat, + claim_deadline, + .. + } => { + let payment_hash_hex = bytes_to_hex(&payment_hash.0); + let claim_deadline_str = match claim_deadline { + Some(deadline) => deadline.to_string(), + None => "None".to_string(), + }; + + eprintln!( + "[lightning-js] PaymentClaimable payment_hash={payment_hash_hex} claimable_amount_msat={claimable_amount_msat} claim_deadline={claim_deadline_str}", + ); + } + Event::PaymentReceived { + payment_hash, + amount_msat, + .. + } => { + let payment_hash_hex = bytes_to_hex(&payment_hash.0); + eprintln!( + "[lightning-js] PaymentReceived payment_hash={payment_hash_hex} amount_msat={amount_msat}", + ); + + // Fire callback IMMEDIATELY + let payment = ReceivedPayment { + payment_hash: payment_hash_hex.clone(), + amount: *amount_msat as i64, + }; + tsfn.call(Ok(payment.clone()), ThreadsafeFunctionCallMode::Blocking); + + received_payments.push(payment); + } + _ => {} + } + + if let Err(err) = self.node.event_handled() { + eprintln!("[lightning-js] Error while marking event handled: {err}"); + } + last_event_time = now; + } + + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + if let Err(err) = self.node.stop() { + eprintln!("[lightning-js] Failed to stop node after receive_payment_with_callback: {err}"); + } + + Ok(received_payments) + } + #[napi] pub fn get_invoice(&self, amount: i64, description: String, expiry_secs: i64) -> PaymentMetadata { let bolt11_invoice_description =