From 7a1ca12296d9379543708886acef87f749021bd1 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Fri, 6 Mar 2026 20:50:18 +0000 Subject: [PATCH 1/7] fix: prevent /release from silently failing when subscriber is dead When the gRPC stream to LND drops, the subscribeToInvoice listener dies silently. If a seller then calls /release, the hold invoice gets settled on LND but the order status never advances because the dead subscriber never fires the is_confirmed callback. The buyer's sats remain stuck. This commit applies three conservative, additive fixes: 1. Subscribe stream resilience (subscribe_invoice.ts): - Add on('error') and on('end') handlers with automatic resubscription after 5 seconds - Log all stream failures for diagnostics 2. Direct verification in release() (commands.ts): - After settleHoldInvoice, verify the invoice is actually confirmed via getInvoice() and call payHoldInvoice() directly - This removes the sole dependency on the subscriber stream - If settle fails, inform the seller instead of swallowing the error 3. Error propagation (hold_invoice.ts): - settleHoldInvoice now re-throws errors after logging instead of silently swallowing them - Callers can now handle failures appropriately 4. Idempotency guard (subscribe_invoice.ts): - payHoldInvoice re-reads the order status before processing - Prevents double-processing if both release() and the subscriber trigger payHoldInvoice for the same order Closes #764 --- bot/commands.ts | 31 +++++++++++++++++++++++++++-- ln/hold_invoice.ts | 3 ++- ln/subscribe_invoice.ts | 43 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 617d45fd..f2918959 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -5,6 +5,7 @@ import { cancelHoldInvoice, settleHoldInvoice, getInvoice, + payHoldInvoice, } from '../ln'; import { Order, User, Dispute } from '../models'; import * as messages from './messages'; @@ -832,9 +833,35 @@ const release = async ( if (order.secret === null) { throw new Error('order.secret is null'); } - await settleHoldInvoice({ secret: order.secret }); + + try { + await settleHoldInvoice({ secret: order.secret }); + } catch (error) { + logger.error( + `release: settleHoldInvoice failed for order ${order._id}: ${error}`, + ); + await ctx.reply(ctx.i18n.t('generic_error')); + return; + } + + // Verify the invoice was actually settled instead of relying + // solely on the subscribeToInvoice stream which can die silently + if (order.hash) { + const invoice = await getInvoice({ hash: order.hash }); + if (invoice && invoice.is_confirmed) { + logger.info( + `release: invoice confirmed for order ${order._id}, proceeding with payHoldInvoice`, + ); + await payHoldInvoice({ telegram: ctx.telegram }, order); + } else { + // The subscriber should pick it up, but log a warning + logger.warning( + `release: invoice not yet confirmed for order ${order._id} after settle call, relying on subscriber`, + ); + } + } } catch (error) { - logger.error(error); + logger.error(`release catch: ${error}`); } }; diff --git a/ln/hold_invoice.ts b/ln/hold_invoice.ts index 0bdf51de..3c113c29 100644 --- a/ln/hold_invoice.ts +++ b/ln/hold_invoice.ts @@ -44,7 +44,8 @@ const settleHoldInvoice = async ({ secret }: { secret: string }) => { try { await lightning.settleHodlInvoice({ lnd, secret }); } catch (error) { - logger.error(error); + logger.error(`settleHoldInvoice failed: ${error}`); + throw error; } }; diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index abd5a8db..e720b64c 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -49,6 +49,35 @@ const subscribeInvoice = async ( ) => { try { const sub = subscribeToInvoice({ id, lnd }); + + sub.on('error', (err: Error) => { + logger.error( + `subscribeInvoice stream error for hash ${id}: ${err.message || err}`, + ); + // Attempt to resubscribe after a short delay + setTimeout(() => { + logger.info(`Attempting to resubscribe invoice with hash ${id}`); + subscribeInvoice(bot, id, true).catch(resubErr => { + logger.error( + `Failed to resubscribe invoice ${id}: ${resubErr}`, + ); + }); + }, 5000); + }); + + sub.on('end', () => { + logger.warning( + `subscribeInvoice stream ended for hash ${id}, attempting resubscription`, + ); + setTimeout(() => { + subscribeInvoice(bot, id, true).catch(resubErr => { + logger.error( + `Failed to resubscribe invoice ${id} after stream end: ${resubErr}`, + ); + }); + }, 5000); + }); + sub.on('invoice_updated', async invoice => { if (invoice.is_held && !resub) { const order = await Order.findOne({ hash: invoice.id }); @@ -135,6 +164,20 @@ const subscribeInvoice = async ( const payHoldInvoice = async (bot: HasTelegram, order: IOrder) => { try { + // Idempotency guard: re-read the order to check if already processed + // This prevents double-processing if both the subscriber and release() + // call this function for the same order + const currentOrder = await Order.findById(order._id); + if ( + currentOrder && + (currentOrder.status === 'PAID_HOLD_INVOICE' || + currentOrder.status === 'SUCCESS') + ) { + logger.info( + `payHoldInvoice: order ${order._id} already in status ${currentOrder.status}, skipping`, + ); + return; + } order.status = 'PAID_HOLD_INVOICE'; await order.save(); const buyerUser = await User.findOne({ _id: order.buyer_id }); From 29119464c0df69e525aaf74b1c436530efe3fbb3 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Fri, 6 Mar 2026 21:00:25 +0000 Subject: [PATCH 2/7] style: fix prettier formatting in subscribe_invoice.ts --- ln/subscribe_invoice.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index e720b64c..62c2ba3c 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -58,9 +58,7 @@ const subscribeInvoice = async ( setTimeout(() => { logger.info(`Attempting to resubscribe invoice with hash ${id}`); subscribeInvoice(bot, id, true).catch(resubErr => { - logger.error( - `Failed to resubscribe invoice ${id}: ${resubErr}`, - ); + logger.error(`Failed to resubscribe invoice ${id}: ${resubErr}`); }); }, 5000); }); From 725254b88ccc9b90890f2582056527c326b9bb07 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Fri, 6 Mar 2026 21:15:09 +0000 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20atomic=20idempotency=20+=20dedupe=20reconnects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Reconnect deduplication (subscribe_invoice.ts): - Add pendingReconnects Set to track in-flight reconnections - Both 'error' and 'end' handlers now check before scheduling - Prevents duplicate subscriptions when gRPC emits both events 2. Atomic idempotency guard (subscribe_invoice.ts): - Wrap payHoldInvoice status check in PerOrderIdMutex - Prevents TOCTOU race between release() and subscriber - Only one caller can transition order to PAID_HOLD_INVOICE --- ln/subscribe_invoice.ts | 80 +++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index 62c2ba3c..399ecfa3 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -15,6 +15,10 @@ type LockCountedMutex = { mutex: Mutex; }; +// Track pending reconnects to prevent duplicate resubscriptions +// when both 'error' and 'end' events fire for the same invoice +const pendingReconnects: Set = new Set(); + class PerOrderIdMutex { mutexes: Map = new Map(); @@ -50,30 +54,38 @@ const subscribeInvoice = async ( try { const sub = subscribeToInvoice({ id, lnd }); + const scheduleResubscribe = (reason: string) => { + if (pendingReconnects.has(id)) { + logger.info( + `subscribeInvoice: reconnect already pending for hash ${id}, skipping (${reason})`, + ); + return; + } + pendingReconnects.add(id); + setTimeout(() => { + logger.info(`Attempting to resubscribe invoice with hash ${id}`); + subscribeInvoice(bot, id, true) + .catch(resubErr => { + logger.error(`Failed to resubscribe invoice ${id}: ${resubErr}`); + }) + .finally(() => { + pendingReconnects.delete(id); + }); + }, 5000); + }; + sub.on('error', (err: Error) => { logger.error( `subscribeInvoice stream error for hash ${id}: ${err.message || err}`, ); - // Attempt to resubscribe after a short delay - setTimeout(() => { - logger.info(`Attempting to resubscribe invoice with hash ${id}`); - subscribeInvoice(bot, id, true).catch(resubErr => { - logger.error(`Failed to resubscribe invoice ${id}: ${resubErr}`); - }); - }, 5000); + scheduleResubscribe('error'); }); sub.on('end', () => { logger.warning( `subscribeInvoice stream ended for hash ${id}, attempting resubscription`, ); - setTimeout(() => { - subscribeInvoice(bot, id, true).catch(resubErr => { - logger.error( - `Failed to resubscribe invoice ${id} after stream end: ${resubErr}`, - ); - }); - }, 5000); + scheduleResubscribe('end'); }); sub.on('invoice_updated', async invoice => { @@ -162,22 +174,30 @@ const subscribeInvoice = async ( const payHoldInvoice = async (bot: HasTelegram, order: IOrder) => { try { - // Idempotency guard: re-read the order to check if already processed - // This prevents double-processing if both the subscriber and release() - // call this function for the same order - const currentOrder = await Order.findById(order._id); - if ( - currentOrder && - (currentOrder.status === 'PAID_HOLD_INVOICE' || - currentOrder.status === 'SUCCESS') - ) { - logger.info( - `payHoldInvoice: order ${order._id} already in status ${currentOrder.status}, skipping`, - ); - return; - } - order.status = 'PAID_HOLD_INVOICE'; - await order.save(); + // Atomic idempotency guard using PerOrderIdMutex to prevent TOCTOU race + // between release() and subscriber both calling payHoldInvoice + const lockedOrder = await PerOrderIdMutex.instance.runExclusive( + String(order._id), + async () => { + const currentOrder = await Order.findById(order._id); + if (currentOrder === null) throw new Error('order was not found'); + if ( + currentOrder.status === 'PAID_HOLD_INVOICE' || + currentOrder.status === 'SUCCESS' + ) { + logger.info( + `payHoldInvoice: order ${order._id} already in status ${currentOrder.status}, skipping`, + ); + return null; + } + currentOrder.status = 'PAID_HOLD_INVOICE'; + await currentOrder.save(); + return currentOrder; + }, + ); + if (lockedOrder === null) return; + // Use the locked order for the rest of the flow + Object.assign(order, { status: lockedOrder.status }); const buyerUser = await User.findOne({ _id: order.buyer_id }); if (buyerUser === null) throw new Error('buyerUser was not found'); const sellerUser = await User.findOne({ _id: order.seller_id }); From ed478e2cc2575a7f5cdd47a74058b5bf765d8c57 Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Sat, 7 Mar 2026 10:52:08 +0000 Subject: [PATCH 4/7] fix: skip resubscription for invoices in terminal state When a hold invoice is settled or canceled, the gRPC stream ends normally (fires 'end' event). Previously, scheduleResubscribe would blindly attempt to resubscribe, creating an infinite 5-second loop for invoices that are already done. Now we check the order status in the database before scheduling a resubscribe. If the order is in a terminal state (SUCCESS, PAID_HOLD_INVOICE, CANCELED, EXPIRED, COMPLETED_BY_ADMIN, CLOSED), we log the skip and return immediately. Fixes the infinite resubscription loop reported in review. --- ln/subscribe_invoice.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index 399ecfa3..b2db5f48 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -19,6 +19,17 @@ type LockCountedMutex = { // when both 'error' and 'end' events fire for the same invoice const pendingReconnects: Set = new Set(); +// Terminal order statuses where the invoice lifecycle is complete +// and resubscription should NOT be attempted +const TERMINAL_STATUSES = new Set([ + 'SUCCESS', + 'PAID_HOLD_INVOICE', + 'CANCELED', + 'EXPIRED', + 'COMPLETED_BY_ADMIN', + 'CLOSED', +]); + class PerOrderIdMutex { mutexes: Map = new Map(); @@ -54,13 +65,34 @@ const subscribeInvoice = async ( try { const sub = subscribeToInvoice({ id, lnd }); - const scheduleResubscribe = (reason: string) => { + const scheduleResubscribe = async (reason: string) => { if (pendingReconnects.has(id)) { logger.info( `subscribeInvoice: reconnect already pending for hash ${id}, skipping (${reason})`, ); return; } + + // Check if the order has reached a terminal state before resubscribing. + // When an invoice is settled or canceled, the gRPC stream ends normally + // (fires 'end' event). Without this check, we'd resubscribe in an + // infinite loop for invoices that are already done. + try { + const order = await Order.findOne({ hash: id }); + if (order && TERMINAL_STATUSES.has(order.status)) { + logger.info( + `subscribeInvoice: order ${order._id} is in terminal status ${order.status}, ` + + `not resubscribing invoice ${id} (${reason})`, + ); + return; + } + } catch (err) { + logger.error( + `subscribeInvoice: failed to check order status for hash ${id}: ${err}`, + ); + // On DB error, still attempt resubscription as a safety measure + } + pendingReconnects.add(id); setTimeout(() => { logger.info(`Attempting to resubscribe invoice with hash ${id}`); From a6efc635fb1821402490eb854c9930cf8708c07e Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" <179+MostronatorCoder[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:55:00 +0000 Subject: [PATCH 5/7] fix: check invoice state via LND instead of order status before resubscribing - Use getInvoice() to check is_confirmed/is_canceled directly from LND instead of checking order status (which may not reflect invoice state) - Remove TERMINAL_STATUSES set (no longer needed) - pendingReconnects guard prevents duplicate subscriptions from simultaneous error+end events --- ln/subscribe_invoice.ts | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index b2db5f48..7a12c6a5 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -1,6 +1,7 @@ import { subscribeToInvoice } from 'lightning'; import { Order, User } from '../models'; import { payToBuyer } from './pay_request'; +import { getInvoice } from './hold_invoice'; import lnd from './connect'; import * as messages from '../bot/messages'; import * as ordersActions from '../bot/ordersActions'; @@ -19,16 +20,7 @@ type LockCountedMutex = { // when both 'error' and 'end' events fire for the same invoice const pendingReconnects: Set = new Set(); -// Terminal order statuses where the invoice lifecycle is complete -// and resubscription should NOT be attempted -const TERMINAL_STATUSES = new Set([ - 'SUCCESS', - 'PAID_HOLD_INVOICE', - 'CANCELED', - 'EXPIRED', - 'COMPLETED_BY_ADMIN', - 'CLOSED', -]); + class PerOrderIdMutex { mutexes: Map = new Map(); @@ -73,24 +65,31 @@ const subscribeInvoice = async ( return; } - // Check if the order has reached a terminal state before resubscribing. + // Check the invoice state directly via LND — this is the source of truth. // When an invoice is settled or canceled, the gRPC stream ends normally // (fires 'end' event). Without this check, we'd resubscribe in an // infinite loop for invoices that are already done. try { - const order = await Order.findOne({ hash: id }); - if (order && TERMINAL_STATUSES.has(order.status)) { + const invoice = await getInvoice({ hash: id }); + if (!invoice) { + logger.info( + `subscribeInvoice: invoice ${id} not found, not resubscribing (${reason})`, + ); + return; + } + if (invoice.is_confirmed || invoice.is_canceled) { logger.info( - `subscribeInvoice: order ${order._id} is in terminal status ${order.status}, ` + - `not resubscribing invoice ${id} (${reason})`, + `subscribeInvoice: invoice ${id} is in terminal state ` + + `(confirmed=${invoice.is_confirmed}, canceled=${invoice.is_canceled}), ` + + `not resubscribing (${reason})`, ); return; } } catch (err) { logger.error( - `subscribeInvoice: failed to check order status for hash ${id}: ${err}`, + `subscribeInvoice: failed to check invoice status for hash ${id}: ${err}`, ); - // On DB error, still attempt resubscription as a safety measure + // On LND error, still attempt resubscription as a safety measure } pendingReconnects.add(id); @@ -106,6 +105,10 @@ const subscribeInvoice = async ( }, 5000); }; + // Use a single combined handler for both error and end events to prevent + // duplicate resubscriptions. When the gRPC stream disconnects, it may fire + // both 'error' and 'end'. The pendingReconnects guard ensures only one + // resubscription happens. sub.on('error', (err: Error) => { logger.error( `subscribeInvoice stream error for hash ${id}: ${err.message || err}`, From 1139aee663b00713672ca4387298ea37b2ce5aca Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Thu, 12 Mar 2026 00:26:08 +0000 Subject: [PATCH 6/7] fix: address PR #765 review feedback on resubscribe and release scope --- bot/commands.ts | 20 ++------------------ ln/subscribe_invoice.ts | 27 ++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index f2918959..1c735f82 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -5,7 +5,6 @@ import { cancelHoldInvoice, settleHoldInvoice, getInvoice, - payHoldInvoice, } from '../ln'; import { Order, User, Dispute } from '../models'; import * as messages from './messages'; @@ -841,25 +840,10 @@ const release = async ( `release: settleHoldInvoice failed for order ${order._id}: ${error}`, ); await ctx.reply(ctx.i18n.t('generic_error')); - return; } - // Verify the invoice was actually settled instead of relying - // solely on the subscribeToInvoice stream which can die silently - if (order.hash) { - const invoice = await getInvoice({ hash: order.hash }); - if (invoice && invoice.is_confirmed) { - logger.info( - `release: invoice confirmed for order ${order._id}, proceeding with payHoldInvoice`, - ); - await payHoldInvoice({ telegram: ctx.telegram }, order); - } else { - // The subscriber should pick it up, but log a warning - logger.warning( - `release: invoice not yet confirmed for order ${order._id} after settle call, relying on subscriber`, - ); - } - } + // Keep release semantics unchanged: payment to buyer is handled by + // subscribe_invoice flow after settlement notifications are processed. } catch (error) { logger.error(`release catch: ${error}`); } diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index 7a12c6a5..010e3e92 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -2,6 +2,7 @@ import { subscribeToInvoice } from 'lightning'; import { Order, User } from '../models'; import { payToBuyer } from './pay_request'; import { getInvoice } from './hold_invoice'; +import { getInfo } from './info'; import lnd from './connect'; import * as messages from '../bot/messages'; import * as ordersActions from '../bot/ordersActions'; @@ -72,6 +73,25 @@ const subscribeInvoice = async ( try { const invoice = await getInvoice({ hash: id }); if (!invoice) { + const walletInfo = await getInfo(); + if (!walletInfo) { + logger.warning( + `subscribeInvoice: wallet info unavailable for hash ${id}; likely LND down, retrying (${reason})`, + ); + pendingReconnects.add(id); + setTimeout(() => { + logger.info(`Attempting to resubscribe invoice with hash ${id}`); + subscribeInvoice(bot, id, true) + .catch(resubErr => { + logger.error(`Failed to resubscribe invoice ${id}: ${resubErr}`); + }) + .finally(() => { + pendingReconnects.delete(id); + }); + }, 5000); + return; + } + logger.info( `subscribeInvoice: invoice ${id} not found, not resubscribing (${reason})`, ); @@ -105,15 +125,12 @@ const subscribeInvoice = async ( }, 5000); }; - // Use a single combined handler for both error and end events to prevent - // duplicate resubscriptions. When the gRPC stream disconnects, it may fire - // both 'error' and 'end'. The pendingReconnects guard ensures only one - // resubscription happens. + // The stream's 'end' event is the single resubscribe trigger. + // 'error' is logged only to avoid duplicate scheduling from both events. sub.on('error', (err: Error) => { logger.error( `subscribeInvoice stream error for hash ${id}: ${err.message || err}`, ); - scheduleResubscribe('error'); }); sub.on('end', () => { From 1aa8766628aad3fcff72b5eb9edc5658e01e66ca Mon Sep 17 00:00:00 2001 From: "MostronatorCoder[bot]" Date: Fri, 13 Mar 2026 22:23:00 +0000 Subject: [PATCH 7/7] fix: address remaining review feedback - Remove 'error' event listener (redundant with 'end') - Revert bot/commands.ts to main (out of scope) - Revert ln/hold_invoice.ts to main (out of scope) - Apply prettier formatting Only ln/subscribe_invoice.ts now modified: - scheduleResubscribe with LND-down resilience - pendingReconnects Set to prevent duplicate subscriptions - Terminal state check (is_confirmed || is_canceled) - Single 'end' event listener for resubscription --- bot/commands.ts | 15 ++------------- ln/hold_invoice.ts | 3 +-- ln/subscribe_invoice.ts | 12 +++--------- 3 files changed, 6 insertions(+), 24 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 1c735f82..617d45fd 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -832,20 +832,9 @@ const release = async ( if (order.secret === null) { throw new Error('order.secret is null'); } - - try { - await settleHoldInvoice({ secret: order.secret }); - } catch (error) { - logger.error( - `release: settleHoldInvoice failed for order ${order._id}: ${error}`, - ); - await ctx.reply(ctx.i18n.t('generic_error')); - } - - // Keep release semantics unchanged: payment to buyer is handled by - // subscribe_invoice flow after settlement notifications are processed. + await settleHoldInvoice({ secret: order.secret }); } catch (error) { - logger.error(`release catch: ${error}`); + logger.error(error); } }; diff --git a/ln/hold_invoice.ts b/ln/hold_invoice.ts index 3c113c29..0bdf51de 100644 --- a/ln/hold_invoice.ts +++ b/ln/hold_invoice.ts @@ -44,8 +44,7 @@ const settleHoldInvoice = async ({ secret }: { secret: string }) => { try { await lightning.settleHodlInvoice({ lnd, secret }); } catch (error) { - logger.error(`settleHoldInvoice failed: ${error}`); - throw error; + logger.error(error); } }; diff --git a/ln/subscribe_invoice.ts b/ln/subscribe_invoice.ts index 010e3e92..53cf0b9b 100644 --- a/ln/subscribe_invoice.ts +++ b/ln/subscribe_invoice.ts @@ -21,8 +21,6 @@ type LockCountedMutex = { // when both 'error' and 'end' events fire for the same invoice const pendingReconnects: Set = new Set(); - - class PerOrderIdMutex { mutexes: Map = new Map(); @@ -83,7 +81,9 @@ const subscribeInvoice = async ( logger.info(`Attempting to resubscribe invoice with hash ${id}`); subscribeInvoice(bot, id, true) .catch(resubErr => { - logger.error(`Failed to resubscribe invoice ${id}: ${resubErr}`); + logger.error( + `Failed to resubscribe invoice ${id}: ${resubErr}`, + ); }) .finally(() => { pendingReconnects.delete(id); @@ -127,12 +127,6 @@ const subscribeInvoice = async ( // The stream's 'end' event is the single resubscribe trigger. // 'error' is logged only to avoid duplicate scheduling from both events. - sub.on('error', (err: Error) => { - logger.error( - `subscribeInvoice stream error for hash ${id}: ${err.message || err}`, - ); - }); - sub.on('end', () => { logger.warning( `subscribeInvoice stream ended for hash ${id}, attempting resubscription`,