Skip to content

Fix/cancel orders expired holdinvoice continued - #884

Open
Matobi98 wants to merge 7 commits into
lnp2pBot:mainfrom
Matobi98:fix/cancel-orders-expired-holdinvoice-continued
Open

Fix/cancel orders expired holdinvoice continued#884
Matobi98 wants to merge 7 commits into
lnp2pBot:mainfrom
Matobi98:fix/cancel-orders-expired-holdinvoice-continued

Conversation

@Matobi98

@Matobi98 Matobi98 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Continues #837 (originally by @grunch).

Ensures that when the cancel-orders job expires an ACTIVE order, the associated Lightning hold invoice is canceled so the seller's locked funds are refunded promptly, instead of being orphaned until the on-chain CLTV timeout. The expiry transition is serialized under the per-order mutex with a state re-check to avoid racing concurrent flows.

Previously, the job marked expired orders as EXPIRED without canceling the hold invoice. Because the expired-hold-invoice check ignores EXPIRED orders, the seller's funds remained locked until the on-chain timeout.

Changes

  • jobs/cancel_orders.ts — Run the expiry transition inside PerOrderIdMutex.instance.runExclusive(orderId, ...) and re-read the order under the lock so the job does not stomp an order that advanced concurrently. Only ACTIVE and FIAT_SENT orders are expired:

    • ACTIVE — the buyer never signalled fiat-sent, so cancel the hold invoice (refund the seller) before marking the order EXPIRED, and notify both buyer and seller that the sats are no longer in escrow. This addresses @Luquitasjeffrey's review comment on fix: cancel hold invoice when expiring ACTIVE orders #837: without the notification, a malicious seller could keep the fiat payment after the order expired silently, since the buyer would have no way of knowing the escrow was no longer backing the trade.
    • FIAT_SENT — the buyer claims to have paid; the hold invoice is left open and the case is logged for the dispute/admin flow rather than auto-refunded.
  • ln/hold_invoice.tscancelHoldInvoice now re-throws LND errors instead of swallowing them, consistent with settleHoldInvoice which already had this behavior with the comment "Callers must not mark an order as settled/frozen if the invoice settlement did not happen." The same reasoning applies here: callers must not mark an order EXPIRED if the hold invoice cancellation did not happen. This is a prerequisite for the error handling in cancel_orders.ts to work correctly — if cancelHoldInvoice fails, the ACTIVE order is left untouched so the next job run retries the cancellation rather than silently leaving the seller's funds locked.

    Behavior change for other call sites: cancelHoldInvoice is also called from bot/commands.ts (cooperative cancel, seller cancel, admin cancel) and bot/start.ts. All of these are wrapped in outer try/catch blocks that log the error. With this change, a LND failure during those flows will now abort the operation silently instead of proceeding with inconsistent state (e.g. marking an order CANCELED while its hold invoice is still live). The order stays in its previous state and the user can retry — safer than before, but without user-facing feedback on failure. A follow-up issue will address surfacing an error message to the user in these cases.

Testing

Manually tested end-to-end against a local regtest LND setup (temporarily lowering ORDER_PUBLISHED_EXPIRATION_WINDOW/HOLD_INVOICE_EXPIRATION_WINDOW to speed up expiry):

  • Sell order happy path
  • Buy order happy path
  • ACTIVE order expiry: hold invoice canceled in LND (confirmed via subscribeInvoice terminal state canceled=true), both parties notified
  • FIAT_SENT order expiry: hold invoice left open, warning logged, left for dispute/admin
  • WAITING_PAYMENT / WAITING_BUYER_INVOICE expiry
  • Dispute flow
  • Cooperative cancellation
  • npx tsc --noEmit, eslint, prettier --check pass on changed files

Summary by CodeRabbit

  • Bug Fixes
    • Improved expired-order processing for active orders with outstanding hold invoices.
    • Eligible orders are refunded, both parties are notified, and status updates are applied consistently.
    • Orders where fiat has already been sent are preserved for dispute handling rather than automatically refunded.
    • Failed invoice cancellations are now reported correctly, preventing orders from being marked as successfully completed when cancellation does not succeed.

grunch and others added 3 commits June 13, 2026 08:33
When the cancel-orders job expires an order, run the transition under
the per-order mutex and re-read the order so it does not stomp an order
that advanced concurrently (e.g. a release/payout in flight under the
same lock).

For ACTIVE orders the buyer never signalled fiat-sent, so cancel the
hold invoice and refund the seller instead of orphaning the payment
hash. Previously the order was marked EXPIRED without canceling the
hold invoice, and since the expired-hold-invoice check ignores EXPIRED
orders, the seller's funds stayed locked until the on-chain CLTV
timeout.

FIAT_SENT orders are not auto-refunded here (the buyer claims to have
paid) and are left for the dispute/admin flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
package.json was bumped to 0.15.2 (commit 056284a) but package-lock.json
still declared 0.15.1. The CI 'Run prettier' step runs 'npm install'
followed by 'git diff --exit-code', and npm rewrites the lockfile
version to match package.json, producing an uncommitted diff that fails
the check. Sync the lockfile version so the working tree stays clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Matobi98, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1caa548d-05e0-42d3-ba0c-2bea32857c5b

📥 Commits

Reviewing files that changed from the base of the PR and between 6ec5fcd and ea7799f.

📒 Files selected for processing (1)
  • tests/jobs/cancel_orders.spec.ts

Walkthrough

The expiration job now locks each order, reloads its state, cancels eligible hold invoices, notifies both parties, preserves FIAT_SENT orders for disputes, and emits refreshed orders as EXPIRED.

Changes

Order expiration handling

Layer / File(s) Summary
Mutex-protected expiration processing
jobs/cancel_orders.ts, ln/hold_invoice.ts
The job reloads and validates each order under a per-order mutex. It cancels eligible hold invoices and rethrows cancellation failures. It notifies both parties and emits successfully expired orders.
Expiration flow validation
tests/jobs/cancel_orders.spec.ts
The tests cover successful cancellation, cancellation failures, missing invoice hashes, and FIAT_SENT orders with dispute warnings.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • lnp2pBot/bot#746: Both PRs modify expired-order and hold-invoice cancellation flows.
  • lnp2pBot/bot#836: Both PRs use per-order locking and refreshed state before hold-invoice operations.
  • lnp2pBot/bot#834: Both PRs rethrow logged hold-invoice operation failures.

Suggested reviewers: luquitasjeffrey, ermeme, grunch

Poem

A rabbit checks each order state,
Then cancels holds before it’s late.
Failed holds remain active still,
Fiat disputes await review.
Expired events hop through.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title identifies the main change: cancelling hold invoices for expired orders.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
jobs/cancel_orders.ts (1)

120-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the ACTIVE and FIAT_SENT handling into named helpers.

The mutex callback now covers reload, status guard, hold-invoice cancellation, dual notifications, dispute logging, and persistence in one function. Extracting the ACTIVE-branch logic (lines 140-167) and the FIAT_SENT-branch logic (lines 168-173) into two named helper functions would shorten the callback and make each branch independently testable.

♻️ Proposed extraction outline
+async function handleActiveOrderExpiration(
+  bot: HasTelegram,
+  updatedOrder: OrderDocument,
+) {
+  if (!updatedOrder.hash) return;
+  await cancelHoldInvoice({ hash: updatedOrder.hash });
+  const buyerUser = await User.findOne({ _id: updatedOrder.buyer_id });
+  const sellerUser = await User.findOne({ _id: updatedOrder.seller_id });
+  if (buyerUser === null || sellerUser === null) return;
+  const i18nCtxBuyer = await getUserI18nContext(buyerUser);
+  const i18nCtxSeller = await getUserI18nContext(sellerUser);
+  await messages.toBuyerHoldInvoiceExpiredMessage(bot, buyerUser, updatedOrder, i18nCtxBuyer);
+  await messages.toSellerHoldInvoiceExpiredMessage(bot, sellerUser, updatedOrder, i18nCtxSeller);
+}
+
+function handleFiatSentOrderExpiration(updatedOrder: OrderDocument) {
+  if (!updatedOrder.hash) return;
+  logger.warn(
+    `Order Id ${updatedOrder.id} expired in FIAT_SENT with an open hold invoice; leaving it for dispute/admin handling`,
+  );
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@jobs/cancel_orders.ts` around lines 120 - 180, The mutex callback in the
order-expiration flow is handling both status-specific branches inline; extract
the ACTIVE handling and FIAT_SENT handling into two named helper functions. Move
the existing hold-invoice cancellation and buyer/seller notification logic into
the ACTIVE helper, and the warning log into the FIAT_SENT helper, then invoke
them from the callback while preserving the current status and hash conditions
and leaving persistence unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@jobs/cancel_orders.ts`:
- Around line 133-141: Ensure the ACTIVE-order expiration flow only sends the
ACTIVE notification and transitions the order to EXPIRED after cancelHoldInvoice
succeeds. In the surrounding cancel-order logic, inspect the result returned by
cancelHoldInvoice for updatedOrder.hash and stop or propagate the failure when
it returns undefined, preserving the existing retry behavior for later runs.

---

Nitpick comments:
In `@jobs/cancel_orders.ts`:
- Around line 120-180: The mutex callback in the order-expiration flow is
handling both status-specific branches inline; extract the ACTIVE handling and
FIAT_SENT handling into two named helper functions. Move the existing
hold-invoice cancellation and buyer/seller notification logic into the ACTIVE
helper, and the warning log into the FIAT_SENT helper, then invoke them from the
callback while preserving the current status and hash conditions and leaving
persistence unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5a03f8ee-dbdd-4fbf-b23e-b546343ab2fd

📥 Commits

Reviewing files that changed from the base of the PR and between 658fb9e and 92c29c2.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (1)
  • jobs/cancel_orders.ts

Comment thread jobs/cancel_orders.ts Outdated
@Matobi98
Matobi98 marked this pull request as draft August 3, 2026 20:02
@Matobi98
Matobi98 marked this pull request as ready for review August 3, 2026 20:41
@Matobi98

Matobi98 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Hey @ToRyVand @knocte — this PR continues #837 (originally by @grunch, open since June with review comments from @Luquitasjeffrey that weren't addressed). I picked it up — added the buyer/seller notifications for the ACTIVE expiry case that Lucas flagged, and also fixed cancelHoldInvoice to re-throw LND errors so the cancellation failure is actually handled instead of swallowed.

Would appreciate a review if you have a moment! Thanks!

@ToRyVand

ToRyVand commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Nice work continuing this @Matobi98 🙏 The mutex + status re-check is the right way to avoid racing a release-in-flight, and leaving the order ACTIVE to retry on failure is clean.

One thing worth a look: making cancelHoldInvoice re-throw also changes the contract for its other call sites — bot/commands.ts (324/549/732/811), bot/start.ts:446, jobs/check_hold_invoice_expired.ts:45. They're wrapped in try/catch so they won't crash, but on an LND failure they'll now skip the post-cancel logic (marking CANCELED/CANCELED_BY_ADMIN, dispute → SELLER_REFUNDED, success messages) and fall through to the catch. More correct, but a silent behavior change in the admin/cooperative-cancel flows — were those audited? A note in the description (and a test) would help.

Minor: FIAT_SENT still gets marked EXPIRED with its hold invoice open, so it can no longer be disputed — pre-existing, just flagging. Overall a real improvement 👍

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/jobs/cancel_orders.spec.ts (1)

32-32: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add coverage for an order that advances before the mutex callback.

Configure expiredOrders with an order ID, then make Order.findById() return an order in a non-expirable status such as COMPLETED. Assert that the job does not cancel the invoice, save the order, or emit orderUpdated. This protects the status re-check that prevents a stale query result from overwriting a concurrent state transition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/jobs/cancel_orders.spec.ts` at line 32, Extend the test setup around
orderFindByIdStub to configure expiredOrders with an order ID and return a
COMPLETED order from Order.findById(). Assert that the job neither cancels the
invoice, saves the order, nor emits orderUpdated when the order advances before
the mutex callback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/jobs/cancel_orders.spec.ts`:
- Around line 67-70: Apply the repository’s Prettier formatting to the proxy
object containing toBuyerExpiredOrderMessage and toSellerExpiredOrderMessage,
using npm run format, and retain the generated formatting changes without
disabling lint or formatting rules.

---

Nitpick comments:
In `@tests/jobs/cancel_orders.spec.ts`:
- Line 32: Extend the test setup around orderFindByIdStub to configure
expiredOrders with an order ID and return a COMPLETED order from
Order.findById(). Assert that the job neither cancels the invoice, saves the
order, nor emits orderUpdated when the order advances before the mutex callback.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f975cab3-ad1e-4b50-88b1-71ac39dab711

📥 Commits

Reviewing files that changed from the base of the PR and between 1378704 and 6257fc5.

📒 Files selected for processing (1)
  • tests/jobs/cancel_orders.spec.ts

Comment thread tests/jobs/cancel_orders.spec.ts Outdated
@Matobi98

Matobi98 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch @ToRyVand — I audited all eight call sites (commands.ts lines 322, 534, 711, 790; start.ts lines 445, 534; check_hold_invoice_expired.ts:45; and cancel_orders.ts:142). In every case they're wrapped in a try/catch that logs the error, so a LND failure now causes the operation to abort instead of proceeding with inconsistent state (e.g. marking an order CANCELED when the hold invoice is still open). That's a behavior change, but a safer one: previously the order could end up in a wrong terminal state with its hold invoice still live; now it stays in its current state and retries or lets the user try again. The tradeoff is that the user gets no feedback when LND is down — I've opened a follow-up issue for that. #899

I've also added explicit tests for the cancelHoldInvoice failure path in cancel_orders.ts and updated the PR description to document the behavior change across call sites.

@ToRyVand

ToRyVand commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thanks for the thorough follow-up @Matobi98 🙏 Auditing all eight sites, documenting the behavior change, adding the failure-path tests, and splitting the no-feedback tradeoff into #899 covers everything I raised. The safer-abort behavior is the right call — LGTM 👍

@Matobi98

Matobi98 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks ToRyVand! Peer review approved — good to merge on my end.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants