Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
Patrick Mulligan
e6513b4797 fix(watchdog): handle LND restarts without locking outgoing operations
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
When the payment index advances (e.g. after an LND restart or external
payment), update the cached offset instead of immediately locking.
Only lock if both a history mismatch AND a balance discrepancy are
detected — indicating a real security concern rather than a benign
LND restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:23:14 -05:00
Patrick Mulligan
b1fd18d45c fix(lnd): wait for chain/graph sync before marking LND ready
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
Warmup() previously only checked that LND responded to GetInfo(), but
did not verify syncedToChain/syncedToGraph. This caused LP to accept
requests while LND was still syncing, leading to "not synced" errors
on every Health() check. Now waits for full sync with a 10min timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:12:20 -05:00
Patrick Mulligan
7973fa83cb fix(nostr): close SimplePool after publishing to prevent connection leak
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
Each sendEvent() call created a new SimplePool() but never closed it,
causing relay WebSocket connections to accumulate indefinitely (~20/min).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:53:09 -05:00
Patrick Mulligan
748a2d3ed6 fix(handlers): await NostrSend calls throughout codebase
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled
Update all NostrSend call sites to properly handle the async nature
of the function now that it returns Promise<void>.

Changes:
- handler.ts: Add async to sendResponse, await nostrSend calls
- debitManager.ts: Add logging for Kind 21002 response sending
- nostrMiddleware.ts: Update nostrSend signature
- tlvFilesStorageProcessor.ts: Update nostrSend signature
- webRTC/index.ts: Add async/await for nostrSend calls

This ensures Kind 21002 (ndebit) responses are properly sent to
wallet clients, fixing the "Debit request failed" issue in ShockWallet.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:38:24 -05:00
Patrick Mulligan
30d818c4d4 fix(nostr): update NostrSend type to Promise<void> with error handling
The NostrSend type was incorrectly typed as returning void when it actually
returns Promise<void>. This caused async errors to be silently swallowed.

Changes:
- Update NostrSend type signature to return Promise<void>
- Make NostrSender._nostrSend default to async function
- Add .catch() error handling in NostrSender.Send() to log failures
- Add logging to track event publishing status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:37:56 -05:00
9 changed files with 58 additions and 35 deletions

View file

@ -105,7 +105,7 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
return { return {
Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop }, Stop: () => { mainHandler.adminManager.setNostrConnected(false); return nostr.Stop },
Send: (...args) => nostr.Send(...args), Send: async (...args) => nostr.Send(...args),
Ping: () => nostr.Ping(), Ping: () => nostr.Ping(),
Reset: (settings: NostrSettings) => nostr.Reset(settings) Reset: (settings: NostrSettings) => nostr.Reset(settings)
} }

View file

@ -142,15 +142,20 @@ export default class {
return new Promise<void>((res, rej) => { return new Promise<void>((res, rej) => {
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { try {
await this.GetInfo() const info = await this.GetInfo()
if (!info.syncedToChain || !info.syncedToGraph) {
this.log("LND responding but not synced yet, waiting...")
return
}
clearInterval(interval) clearInterval(interval)
this.ready = true this.ready = true
res() res()
} catch (err) { } catch (err) {
this.log("LND is not ready yet, will try again in 1 second") this.log("LND is not ready yet, will try again in 1 second")
if (Date.now() - now > 1000 * 60) {
rej(new Error("LND not ready after 1 minute"))
} }
if (Date.now() - now > 1000 * 60 * 10) {
clearInterval(interval)
rej(new Error("LND not synced after 10 minutes"))
} }
}, 1000) }, 1000)
}) })

View file

@ -153,13 +153,14 @@ export class DebitManager {
} }
notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => { notifyPaymentSuccess = (debitRes: NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
this.logger("✅ [DEBIT REQUEST] Payment successful, sending OK response to", event.pub.slice(0, 16) + "...", "for event", event.id.slice(0, 16) + "...")
this.sendDebitResponse(debitRes, event) this.sendDebitResponse(debitRes, event)
} }
sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => { sendDebitResponse = (debitRes: NdebitFailure | NdebitSuccess, event: { pub: string, id: string, appId: string }) => {
this.logger("📤 [DEBIT RESPONSE] Sending Kind 21002 response:", JSON.stringify(debitRes), "to", event.pub.slice(0, 16) + "...")
const e = newNdebitResponse(JSON.stringify(debitRes), event) const e = newNdebitResponse(JSON.stringify(debitRes), event)
this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } }) this.storage.NostrSender().Send({ type: 'app', appId: event.appId }, { type: 'event', event: e, encrypt: { toPub: event.pub } })
} }
payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => { payNdebitInvoice = async (event: NostrEvent, pointerdata: NdebitData): Promise<HandleNdebitRes> => {

View file

@ -196,15 +196,19 @@ export class Watchdog {
const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset) const knownMaxIndex = Math.max(maxFromDb, this.latestPaymentIndexOffset)
const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex) const newLatest = await this.lnd.GetLatestPaymentIndex(knownMaxIndex)
const historyMismatch = newLatest > knownMaxIndex const historyMismatch = newLatest > knownMaxIndex
if (historyMismatch) {
this.log("Payment index advanced from", knownMaxIndex, "to", newLatest, "- updating offset (likely LND restart or external payment)")
this.latestPaymentIndexOffset = newLatest
}
const other = { ilnd: this.initialLndBalance, hf: this.accumulatedHtlcFees, iu: this.initialUsersBalance, tu: totalUsersBalance, km: knownMaxIndex, nl: newLatest, oext: otherExternal } const other = { ilnd: this.initialLndBalance, hf: this.accumulatedHtlcFees, iu: this.initialUsersBalance, tu: totalUsersBalance, km: knownMaxIndex, nl: newLatest, oext: otherExternal }
//getLogger({ component: 'watchdog_debug2' })(JSON.stringify({ deltaLnd, deltaUsers, totalExternal, other })) //getLogger({ component: 'watchdog_debug2' })(JSON.stringify({ deltaLnd, deltaUsers, totalExternal, other }))
const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers) const deny = await this.checkBalanceUpdate(deltaLnd, deltaUsers)
if (deny) {
if (historyMismatch) { if (historyMismatch) {
getLogger({ component: 'bark' })("History mismatch detected in absolute update, locking outgoing operations") getLogger({ component: 'bark' })("Balance mismatch with unexpected payment history, locking outgoing operations")
this.lnd.LockOutgoingOperations() this.lnd.LockOutgoingOperations()
return return
} }
if (deny) {
this.log("Balance mismatch detected in absolute update, but history is ok") this.log("Balance mismatch detected in absolute update, but history is ok")
} }
this.lnd.UnlockOutgoingOperations() this.lnd.UnlockOutgoingOperations()

View file

@ -132,12 +132,12 @@ const handleNostrSettings = (settings: NostrSettings) => {
send(event) send(event)
}) })
} */ } */
const sendToNostr: NostrSend = (initiator, data, relays) => { const sendToNostr: NostrSend = async (initiator, data, relays) => {
if (!subProcessHandler) { if (!subProcessHandler) {
getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized") getLogger({ component: "nostrMiddleware" })(ERROR, "nostr was not initialized")
return return
} }
subProcessHandler.Send(initiator, data, relays) await subProcessHandler.Send(initiator, data, relays)
} }
send({ type: 'ready' }) send({ type: 'ready' })

View file

@ -16,7 +16,7 @@ export type SendDataContent = { type: "content", content: string, pub: string }
export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } } export type SendDataEvent = { type: "event", event: UnsignedEvent, encrypt?: { toPub: string } }
export type SendData = SendDataContent | SendDataEvent export type SendData = SendDataContent | SendDataEvent
export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string } export type SendInitiator = { type: 'app', appId: string } | { type: 'client', clientId: string }
export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => void export type NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => Promise<void>
export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string } export type LinkedProviderInfo = { pubkey: string, clientId: string, relayUrl: string }
export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo } export type AppInfo = { appId: string, publicKey: string, privateKey: string, name: string, provider?: LinkedProviderInfo }
@ -203,21 +203,26 @@ export class NostrPool {
const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex')) const signed = finalizeEvent(event, Buffer.from(keys.privateKey, 'hex'))
let sent = false let sent = false
const log = getLogger({ appName: keys.name }) const log = getLogger({ appName: keys.name })
// const r = relays ? relays : this.getServiceRelays() this.log(`📤 Publishing Kind ${event.kind} event to ${relays.length} relay(s): ${relays.join(', ')}`)
const pool = new SimplePool() const pool = new SimplePool()
try {
await Promise.all(pool.publish(relays, signed).map(async p => { await Promise.all(pool.publish(relays, signed).map(async p => {
try { try {
await p await p
sent = true sent = true
} catch (e: any) { } catch (e: any) {
console.log(e) this.log(ERROR, `Failed to publish Kind ${event.kind} event:`, e.message || e)
log(e) log(e)
} }
})) }))
if (!sent) { if (!sent) {
this.log(ERROR, `Failed to send Kind ${event.kind} event to any relay`)
log("failed to send event") log("failed to send event")
} else { } else {
//log("sent event") this.log(`✅ Kind ${event.kind} event published successfully (id: ${signed.id.slice(0, 16)}...)`)
}
} finally {
pool.close(relays)
} }
} }

View file

@ -1,7 +1,7 @@
import { NostrSend, SendData, SendInitiator } from "./nostrPool.js" import { NostrSend, SendData, SendInitiator } from "./nostrPool.js"
import { getLogger } from "../helpers/logger.js" import { ERROR, getLogger } from "../helpers/logger.js"
export class NostrSender { export class NostrSender {
private _nostrSend: NostrSend = () => { throw new Error('nostr send not initialized yet') } private _nostrSend: NostrSend = async () => { throw new Error('nostr send not initialized yet') }
private isReady: boolean = false private isReady: boolean = false
private onReadyCallbacks: (() => void)[] = [] private onReadyCallbacks: (() => void)[] = []
private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = [] private pendingSends: { initiator: SendInitiator, data: SendData, relays?: string[] | undefined }[] = []
@ -12,7 +12,12 @@ export class NostrSender {
this.isReady = true this.isReady = true
this.onReadyCallbacks.forEach(cb => cb()) this.onReadyCallbacks.forEach(cb => cb())
this.onReadyCallbacks = [] this.onReadyCallbacks = []
this.pendingSends.forEach(send => this._nostrSend(send.initiator, send.data, send.relays)) // Process pending sends with proper error handling
this.pendingSends.forEach(send => {
this._nostrSend(send.initiator, send.data, send.relays).catch(e => {
this.log(ERROR, "failed to send pending event", e.message || e)
})
})
this.pendingSends = [] this.pendingSends = []
} }
OnReady(callback: () => void) { OnReady(callback: () => void) {
@ -22,13 +27,16 @@ export class NostrSender {
this.onReadyCallbacks.push(callback) this.onReadyCallbacks.push(callback)
} }
} }
Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined) { Send(initiator: SendInitiator, data: SendData, relays?: string[] | undefined): void {
if (!this.isReady) { if (!this.isReady) {
this.log("tried to send before nostr was ready, caching request") this.log("tried to send before nostr was ready, caching request")
this.pendingSends.push({ initiator, data, relays }) this.pendingSends.push({ initiator, data, relays })
return return
} }
this._nostrSend(initiator, data, relays) // Fire and forget but log errors
this._nostrSend(initiator, data, relays).catch(e => {
this.log(ERROR, "failed to send event", e.message || e)
})
} }
IsReady() { IsReady() {
return this.isReady return this.isReady

View file

@ -126,7 +126,7 @@ class TlvFilesStorageProcessor {
throw new Error('Unknown metric type: ' + t) throw new Error('Unknown metric type: ' + t)
} }
}) })
this.wrtc.attachNostrSend((initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { this.wrtc.attachNostrSend(async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
this.sendResponse({ this.sendResponse({
success: true, success: true,
type: 'nostrSend', type: 'nostrSend',

View file

@ -27,11 +27,11 @@ export default class webRTC {
attachNostrSend(f: NostrSend) { attachNostrSend(f: NostrSend) {
this._nostrSend = f this._nostrSend = f
} }
private nostrSend: NostrSend = (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => { private nostrSend: NostrSend = async (initiator: SendInitiator, data: SendData, relays?: string[] | undefined) => {
if (!this._nostrSend) { if (!this._nostrSend) {
throw new Error("No nostrSend attached") throw new Error("No nostrSend attached")
} }
this._nostrSend(initiator, data, relays) await this._nostrSend(initiator, data, relays)
} }
private sendCandidate = (u: WebRtcUserInfo, candidate: string) => { private sendCandidate = (u: WebRtcUserInfo, candidate: string) => {