fix: prevent /release from silently failing when subscriber is dead#765
fix: prevent /release from silently failing when subscriber is dead#765
Conversation
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
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughError handling and resilience improvements across invoice settlement flows. Changes include try/catch wrapping of Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip You can disable poems in the walkthrough.Disable the |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
bot/commands.ts (1)
828-845:⚠️ Potential issue | 🟠 MajorPersist the dispute transition only after settlement succeeds.
Right now a failed
settleHoldInvoice()returns at Line 844 after the dispute has already been saved asRELEASED. That leaves the dispute state ahead of the actual Lightning state. Move the dispute update below the successful settlement path.Possible fix
- if (dispute) { - dispute.status = 'RELEASED'; - await dispute.save(); - } - if (order.secret === null) { throw new Error('order.secret is null'); } @@ try { await settleHoldInvoice({ secret: order.secret }); } catch (error) { @@ await ctx.reply(ctx.i18n.t('generic_error')); return; } + + if (dispute) { + dispute.status = 'RELEASED'; + await dispute.save(); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@bot/commands.ts` around lines 828 - 845, The dispute is being marked and saved as 'RELEASED' before settleHoldInvoice completes; move the mutation and call to dispute.save() so it only runs after settleHoldInvoice({ secret: order.secret }) succeeds. Specifically, keep the pre-check for order.secret and the try/catch around settleHoldInvoice (and the existing logger.error and ctx.reply in the catch), but relocate the lines that set dispute.status = 'RELEASED' and await dispute.save() to immediately after the successful settleHoldInvoice call so the dispute state reflects the actual Lightning settlement.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@ln/subscribe_invoice.ts`:
- Around line 165-180: The idempotency guard in payHoldInvoice is vulnerable to
TOCTOU; instead of plain Order.findById + save, make the transition to
'PAID_HOLD_INVOICE' atomic by either acquiring the PerOrderIdMutex for this
order id around the read-modify-save block (so only one caller runs
payHoldInvoice/release side effects like payToBuyer), or perform a conditional
atomic update with Order.findOneAndUpdate({ _id: order._id, status: { $nin:
['PAID_HOLD_INVOICE','SUCCESS'] } }, { $set: { status: 'PAID_HOLD_INVOICE' } },
{ new: true }) and treat a null result as “already processed” (skip side
effects); ensure the chosen approach also prevents release from proceeding
concurrently.
- Around line 53-77: The error/end handlers in subscribeInvoice create duplicate
resubscriptions for the same invoice; introduce a shared per-invoice pending
reconnect tracker (e.g., a Set or Map named pendingReconnects) and check
pendingReconnects.has(id) before scheduling setTimeout in both sub.on('error',
...) and sub.on('end', ...), add the id to pendingReconnects when scheduling the
reconnect, and remove it when the resubscription attempt completes or when
subscribeInvoice succeeds; also consult/mark the same pendingReconnects from the
startup resubscribe logic (ln/resubscribe_invoices.ts) so that startup
resubscribe and runtime handlers use the same dedupe mechanism; reference
functions/variables subscribeInvoice and PerOrderIdMutex to locate where to add
the check and tracker.
---
Outside diff comments:
In `@bot/commands.ts`:
- Around line 828-845: The dispute is being marked and saved as 'RELEASED'
before settleHoldInvoice completes; move the mutation and call to dispute.save()
so it only runs after settleHoldInvoice({ secret: order.secret }) succeeds.
Specifically, keep the pre-check for order.secret and the try/catch around
settleHoldInvoice (and the existing logger.error and ctx.reply in the catch),
but relocate the lines that set dispute.status = 'RELEASED' and await
dispute.save() to immediately after the successful settleHoldInvoice call so the
dispute state reflects the actual Lightning settlement.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 84609212-343e-4e3c-a70d-e64143215dfd
📒 Files selected for processing (3)
bot/commands.tsln/hold_invoice.tsln/subscribe_invoice.ts
ln/subscribe_invoice.ts
Outdated
| 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); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -t f "subscribe_invoice.ts" -o | head -20Repository: lnp2pBot/bot
Length of output: 172
🏁 Script executed:
fd -t f "resubscribe_invoices.ts" -o | head -20Repository: lnp2pBot/bot
Length of output: 172
🏁 Script executed:
git ls-files | grep -E "(subscribe|invoice)" | head -20Repository: lnp2pBot/bot
Length of output: 147
🏁 Script executed:
cat -n ln/subscribe_invoice.tsRepository: lnp2pBot/bot
Length of output: 9936
🏁 Script executed:
cat -n ln/resubscribe_invoices.tsRepository: lnp2pBot/bot
Length of output: 1689
🏁 Script executed:
find . -name "package.json" | head -5Repository: lnp2pBot/bot
Length of output: 70
🏁 Script executed:
cat package.json | grep -A 2 -B 2 '"lightning"'Repository: lnp2pBot/bot
Length of output: 259
🏁 Script executed:
rg "subscribeToInvoice|subscribeToProbe" -A 10 -B 2 --type tsRepository: lnp2pBot/bot
Length of output: 4308
🏁 Script executed:
rg -i "stream.*error|stream.*end|error.*end" -B 3 -A 3 --type ts | head -60Repository: lnp2pBot/bot
Length of output: 2880
🏁 Script executed:
rg -i "grpc|stream" --type ts -B 2 -A 2 | head -40Repository: lnp2pBot/bot
Length of output: 1895
🏁 Script executed:
rg "Mutex|Lock" -B 2 -A 2 --type ts | grep -E "(subscribe|Invoice)" -B 2 -A 2Repository: lnp2pBot/bot
Length of output: 2037
Deduplicate reconnects per invoice to prevent resource exhaustion.
Both the error and end handlers independently schedule subscribeInvoice(bot, id, true) after 5 seconds. Since gRPC streams emit both error and end events on failure, both timers will fire, creating two concurrent subscriptions for the same invoice. This effect multiplies on cascading failures. The existing PerOrderIdMutex only protects the invoice_updated callback logic, not subscription creation itself. Additionally, the startup resubscription in ln/resubscribe_invoices.ts can race with these runtime handlers. Track one pending reconnect per id before scheduling timers to ensure only one subscription per invoice is active at any time.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ln/subscribe_invoice.ts` around lines 53 - 77, The error/end handlers in
subscribeInvoice create duplicate resubscriptions for the same invoice;
introduce a shared per-invoice pending reconnect tracker (e.g., a Set or Map
named pendingReconnects) and check pendingReconnects.has(id) before scheduling
setTimeout in both sub.on('error', ...) and sub.on('end', ...), add the id to
pendingReconnects when scheduling the reconnect, and remove it when the
resubscription attempt completes or when subscribeInvoice succeeds; also
consult/mark the same pendingReconnects from the startup resubscribe logic
(ln/resubscribe_invoices.ts) so that startup resubscribe and runtime handlers
use the same dedupe mechanism; reference functions/variables subscribeInvoice
and PerOrderIdMutex to locate where to add the check and tracker.
There was a problem hiding this comment.
Fixed in 725254b. Added a pendingReconnects: Set<string> tracker — both error and end handlers check pendingReconnects.has(id) before scheduling, and clean up via .finally() after the resubscription attempt completes or fails.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
ln/subscribe_invoice.ts
Outdated
| // 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(); |
There was a problem hiding this comment.
Make the idempotency guard atomic.
This is still a TOCTOU race. If /release and the subscriber call payHoldInvoice() at the same time, both can read a pre-paid status, then both save and run the side effects below, including payToBuyer(). Reuse PerOrderIdMutex here or switch to a conditional findOneAndUpdate() so only one caller can transition the order into PAID_HOLD_INVOICE.
Possible fix
const payHoldInvoice = async (bot: HasTelegram, order: IOrder) => {
try {
- 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 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;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ln/subscribe_invoice.ts` around lines 165 - 180, The idempotency guard in
payHoldInvoice is vulnerable to TOCTOU; instead of plain Order.findById + save,
make the transition to 'PAID_HOLD_INVOICE' atomic by either acquiring the
PerOrderIdMutex for this order id around the read-modify-save block (so only one
caller runs payHoldInvoice/release side effects like payToBuyer), or perform a
conditional atomic update with Order.findOneAndUpdate({ _id: order._id, status:
{ $nin: ['PAID_HOLD_INVOICE','SUCCESS'] } }, { $set: { status:
'PAID_HOLD_INVOICE' } }, { new: true }) and treat a null result as “already
processed” (skip side effects); ensure the chosen approach also prevents release
from proceeding concurrently.
There was a problem hiding this comment.
Fixed in 725254b. Wrapped the status check + save in PerOrderIdMutex.instance.runExclusive() — now only one caller can transition the order to PAID_HOLD_INVOICE. If the second caller finds the order already transitioned, it returns early without running side effects.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
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
There was a problem hiding this comment.
It solves the described problem but it created a new bug:
If the stream ended this code will attempt to resubscribe anyways, the problem is that when a hold invoice is either canceled or settled, the stream will end and this code will try to resubscribe anyway.
Look the logs:
[2026-03-06T19:56:07.383-03:00] info: Attempting to resubscribe invoice with hash ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125
[2026-03-06T19:56:07.389-03:00] warning: subscribeInvoice stream ended for hash ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125, attempting resubscription
[2026-03-06T19:56:07.390-03:00] info: Order 69ab5a4a46fa9c85e099ff07 - Invoice with hash: ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125 was settled!
[2026-03-06T19:56:07.393-03:00] info: payHoldInvoice: order 69ab5a4a46fa9c85e099ff07 already in status PAID_HOLD_INVOICE, skipping
[2026-03-06T19:56:12.390-03:00] info: Attempting to resubscribe invoice with hash ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125
[2026-03-06T19:56:12.396-03:00] warning: subscribeInvoice stream ended for hash ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125, attempting resubscription
[2026-03-06T19:56:12.397-03:00] info: Order 69ab5a4a46fa9c85e099ff07 - Invoice with hash: ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125 was settled!
[2026-03-06T19:56:12.400-03:00] info: payHoldInvoice: order 69ab5a4a46fa9c85e099ff07 already in status PAID_HOLD_INVOICE, skipping
^C[2026-03-06T19:56:17.395-03:00] info: Attempting to resubscribe invoice with hash ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125
[2026-03-06T19:56:17.401-03:00] warning: subscribeInvoice stream ended for hash ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125, attempting resubscription
[2026-03-06T19:56:17.402-03:00] info: Order 69ab5a4a46fa9c85e099ff07 - Invoice with hash: ea7656963a311d13d3ceaf8f8050cc7a955f53c22bda4cfecd764e63bfcb3125 was settled!
[2026-03-06T19:56:17.405-03:00] info: payHoldInvoice: order 69ab5a4a46fa9c85e099ff07 already in status PAID_HOLD_INVOICE, skipping
It should try to resubscribe the invoice if the invoice is not SETTLED nor CANCELED nor EXPIRED, only for invoices that are being held or invoices that are not yet paid
There was a problem hiding this comment.
You're absolutely right @Luquitasjeffrey — good catch. The end event fires normally when the invoice reaches a terminal state (settled/canceled), and the current code blindly attempts resubscription without checking whether the invoice is already in a final state.
The fix is straightforward: before scheduling a resubscribe in scheduleResubscribe(), check the order status in the database. If the order is already in a terminal state (PAID_HOLD_INVOICE, SUCCESS, CANCELED, EXPIRED, COMPLETED_BY_ADMIN), skip resubscription entirely — the stream ended because the invoice lifecycle is done, not because of a network error.
I'll push a fix shortly.
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.
Luquitasjeffrey
left a comment
There was a problem hiding this comment.
Each time the stream is disconnected it creates 2 new subscriptions, leading to an exponential number of subscriptions to the same invoice. After the stream ends it should reconnect only once
[2026-03-10T18:06:02.344-03:00] warning: subscribeInvoice stream ended for hash c67548ddfc01e43b8f2e815d2b691a4ffe9892e41da04f06c5befe33965c4fb8, attempting resubscription
[2026-03-10T18:06:07.365-03:00] info: Attempting to resubscribe invoice with hash c67548ddfc01e43b8f2e815d2b691a4ffe9892e41da04f06c5befe33965c4fb8
[2026-03-10T18:06:07.403-03:00] error: subscribeInvoice stream error for hash c67548ddfc01e43b8f2e815d2b691a4ffe9892e41da04f06c5befe33965c4fb8: 2 UNKNOWN: the RPC server is in the process of starting up, but not yet ready to accept calls
[2026-03-10T18:06:07.406-03:00] warning: subscribeInvoice stream ended for hash c67548ddfc01e43b8f2e815d2b691a4ffe9892e41da04f06c5befe33965c4fb8, attempting resubscription
[2026-03-10T18:06:12.420-03:00] info: Attempting to resubscribe invoice with hash c67548ddfc01e43b8f2e815d2b691a4ffe9892e41da04f06c5befe33965c4fb8
[2026-03-10T18:06:12.427-03:00] info: Attempting to resubscribe invoice with hash c67548ddfc01e43b8f2e815d2b691a4ffe9892e41da04f06c5befe33965c4fb8
ln/subscribe_invoice.ts
Outdated
| // infinite loop for invoices that are already done. | ||
| try { | ||
| const order = await Order.findOne({ hash: id }); | ||
| if (order && TERMINAL_STATUSES.has(order.status)) { |
There was a problem hiding this comment.
You need to check the status of the invoice, not the status of the Order:
const invoice = await getInvoice({ hash: id }); if (!invoice) { return; } if (invoice.is_confirmed || invoice.is_canceled) { // invoice is not subscribable as its process has finished return; }
There was a problem hiding this comment.
Good call — fixed in a6efc63. Now using getInvoice({ hash: id }) from LND directly and checking invoice.is_confirmed || invoice.is_canceled before resubscribing. Removed the TERMINAL_STATUSES set entirely since we no longer check order status.
The pendingReconnects Set guard remains to prevent the exponential subscription issue: if both error and end fire for the same hash, only the first one schedules a resubscription.
…scribing - 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
|
@Luquitasjeffrey Both issues addressed in
Please review when you get a chance. |
| const invoice = await getInvoice({ hash: id }); | ||
| if (!invoice) { | ||
| logger.info( | ||
| `subscribeInvoice: invoice ${id} not found, not resubscribing (${reason})`, | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
If LND is down invoice will always be null and will never get resubscribed again. To check if LND is down, this code works to check it:
if (!invoice) {
const walletInfo = await getInfo();
if (!walletInfo) {
// possibly LND is down, we need to reschedule resubscribing
setTimeout(() => scheduleResubscribe(reason), 5000);
return;
}
logger.info(
subscribeInvoice: invoice ${id} not found, not resubscribing (${reason}),
);
return;
}
ln/subscribe_invoice.ts
Outdated
| // 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}`, | ||
| ); | ||
| scheduleResubscribe('error'); | ||
| }); | ||
|
|
There was a problem hiding this comment.
This should be removed, 'end' event will always get emitted, you can subscribe only to 'end' to avoid calling twice the scheduleResubscribe() function
bot/commands.ts
Outdated
| // 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}`); | ||
| } |
There was a problem hiding this comment.
This shouldn't be here, paying to the buyer in any /release if the invoice was settled can introduce a potential vulnerability, as it is already handled in subscribe_invoice.
also, the goal of this pr is to solve the issue when the subscriber is dead.
Edge cases like this are better to be handled manually by administrators, leave this file as it was before
|
Thanks, verified each comment against current code and fixed only where needed in Applied fixes
Validation
|
Luquitasjeffrey
left a comment
There was a problem hiding this comment.
Please revert the changes in the files commands.ts and hold_invoice.ts, keep only the changes on subscribeToInvoice
the changes on subscribe to invoice they work, I tested and it solves the problem of a dead subscriber, and does not introduce any new bugs now.
The new logic in subscribe_invoice.ts SHOULD NOT BE CHANGED, only fix formatting errors.
Remember to run npm run format before doing commit.
bot/commands.ts
Outdated
|
|
||
| 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. | ||
| } catch (error) { | ||
| logger.error(error); | ||
| logger.error(`release catch: ${error}`); |
There was a problem hiding this comment.
This file should be unchanged in respect to main. The changes don't look bad but its outside the scope of this PR
This PR should solve only the problem that arises when the subscriber of an invoice is broken.
Revert this changes please git restore --source=main bot/commands.ts
There was a problem hiding this comment.
In any case this, and the change in hold_invoice.ts can be continued in a follow-up pr, but not in this one. Lets keep the pr focused
| }; | ||
|
|
||
| const settleHoldInvoice = async ({ secret }: { secret: string }) => { | ||
| try { |
There was a problem hiding this comment.
Same as above, this is outside the scope of this PR
This PR should solve only the problem that arises when the subscriber of an invoice is broken.
Revert this changes please git restore --source=main ln/hold_invoice.ts
- 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
|
All remaining review feedback addressed in Changes Applied
Final ScopeOnly
No changes to:
PR now strictly focused on fixing dead subscriber issue as requested. Ready for re-review. |
Summary
Fixes #764 —
/releasesometimes silently fails: the hold invoice gets settled on LND but the order status never advances, leaving the buyer's sats stuck.Root Cause
release()callssettleHoldInvoice()but relies entirely on thesubscribeToInvoicegRPC stream to detectis_confirmedand triggerpayHoldInvoice(). If the stream dies (intermittent LND communication issues), the event is lost and the order is stuck forever.Changes
1. Subscribe stream resilience (
ln/subscribe_invoice.ts)on('error')handler — logs the error and resubscribes after 5son('end')handler — logs and resubscribes after 5s2. Direct verification in
release()(bot/commands.ts)settleHoldInvoice(), callsgetInvoice({ hash })to verify the invoice is confirmedpayHoldInvoice()directly — no longer depends solely on the subscriberctx.reply3. Error propagation (
ln/hold_invoice.ts)settleHoldInvoicenow re-throws errors after loggingundefined4. Idempotency guard (
ln/subscribe_invoice.ts)payHoldInvoicere-reads the order from DB before processingPAID_HOLD_INVOICEorSUCCESS, skips processingrelease()and the subscriber trigger itWhat's NOT in this PR
Keeping the scope minimal to avoid introducing risk:
Testing
npx tsc --noEmit✅ compiles cleanRisk Assessment
Low risk — all changes are additive:
Summary by CodeRabbit