From 3de394db515dfb829d69141a1bb84b6a7a774ecc Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 13 Jun 2026 08:33:57 -0300 Subject: [PATCH 1/7] fix: cancel hold invoice when expiring ACTIVE orders 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) --- jobs/cancel_orders.ts | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/jobs/cancel_orders.ts b/jobs/cancel_orders.ts index 9c46a0ef..9201de66 100644 --- a/jobs/cancel_orders.ts +++ b/jobs/cancel_orders.ts @@ -1,6 +1,7 @@ import { HasTelegram } from '../bot/start'; import { User, Order } from '../models'; import { cancelShowHoldInvoice, cancelAddInvoice } from '../bot/commands'; +import { cancelHoldInvoice } from '../ln'; import * as messages from '../bot/messages'; import { getUserI18nContext, @@ -116,10 +117,41 @@ const cancelOrders = async (bot: HasTelegram) => { ], }); for (const order of expiredOrders) { - order.status = 'EXPIRED'; - await order.save(); - OrderEvents.orderUpdated(order); - logger.info(`Order Id ${order.id} expired!`); + await PerOrderIdMutex.instance.runExclusive( + String(order._id), + async () => { + const updatedOrder = await Order.findById(order._id); + if (!updatedOrder) return; + // Don't stomp an order that advanced after the find above (e.g. a + // release/payout currently in flight under the same mutex). + if ( + updatedOrder.status !== 'ACTIVE' && + updatedOrder.status !== 'FIAT_SENT' + ) + return; + + // For ACTIVE orders the buyer never signalled fiat-sent, so it is + // safe to cancel the hold invoice and refund the seller instead of + // orphaning the hash. check_hold_invoice_expired ignores EXPIRED + // orders, so without this the seller's funds would stay 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. + if (updatedOrder.status === 'ACTIVE' && updatedOrder.hash) { + await cancelHoldInvoice({ hash: updatedOrder.hash }); + } else if (updatedOrder.status === 'FIAT_SENT' && updatedOrder.hash) { + logger.warning( + `Order Id ${updatedOrder.id} expired in FIAT_SENT with an open ` + + `hold invoice; leaving it for dispute/admin handling`, + ); + } + + updatedOrder.status = 'EXPIRED'; + await updatedOrder.save(); + OrderEvents.orderUpdated(updatedOrder); + logger.info(`Order Id ${updatedOrder.id} expired!`); + }, + ); } } catch (error) { logger.error(error); From 96593e4c3ceda975825ea308794360a7a54f934c Mon Sep 17 00:00:00 2001 From: grunch Date: Sat, 13 Jun 2026 08:49:18 -0300 Subject: [PATCH 2/7] chore: sync package-lock.json version to 0.15.2 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) --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 51dd9824..bed012ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lnp2pbot", - "version": "0.15.1", + "version": "0.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lnp2pbot", - "version": "0.15.1", + "version": "0.15.2", "license": "MIT", "dependencies": { "@grammyjs/i18n": "^0.5.1", From 92c29c21b6c0120a8582ce93bea1e3d362e29cd2 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 3 Aug 2026 16:43:11 -0300 Subject: [PATCH 3/7] feat: notify buyer and seller when an ACTIVE order's hold invoice is canceled on expiry --- jobs/cancel_orders.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/jobs/cancel_orders.ts b/jobs/cancel_orders.ts index 9201de66..28c848a3 100644 --- a/jobs/cancel_orders.ts +++ b/jobs/cancel_orders.ts @@ -139,6 +139,32 @@ const cancelOrders = async (bot: HasTelegram) => { // dispute/admin flow. if (updatedOrder.status === 'ACTIVE' && updatedOrder.hash) { await cancelHoldInvoice({ hash: updatedOrder.hash }); + + // The seller's sats are no longer in escrow: warn both parties so + // the buyer doesn't send fiat off-platform expecting the hold + // invoice to still be backing the trade. + const buyerUser = await User.findOne({ + _id: updatedOrder.buyer_id, + }); + const sellerUser = await User.findOne({ + _id: updatedOrder.seller_id, + }); + if (buyerUser !== null && sellerUser !== null) { + const i18nCtxBuyer = await getUserI18nContext(buyerUser); + const i18nCtxSeller = await getUserI18nContext(sellerUser); + await messages.toBuyerHoldInvoiceExpiredMessage( + bot, + buyerUser, + updatedOrder, + i18nCtxBuyer, + ); + await messages.toSellerHoldInvoiceExpiredMessage( + bot, + sellerUser, + updatedOrder, + i18nCtxSeller, + ); + } } else if (updatedOrder.status === 'FIAT_SENT' && updatedOrder.hash) { logger.warning( `Order Id ${updatedOrder.id} expired in FIAT_SENT with an open ` + From 1378704e6a1cf5a01b73e347e28532f8f2770a1a Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Mon, 3 Aug 2026 17:40:51 -0300 Subject: [PATCH 4/7] fix: retry hold invoice cancellation on failure instead of marking order EXPIRED --- jobs/cancel_orders.ts | 10 +++++++++- ln/hold_invoice.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/jobs/cancel_orders.ts b/jobs/cancel_orders.ts index 28c848a3..3d8ab519 100644 --- a/jobs/cancel_orders.ts +++ b/jobs/cancel_orders.ts @@ -138,8 +138,16 @@ const cancelOrders = async (bot: HasTelegram) => { // here (the buyer claims to have paid) and are left for the // dispute/admin flow. if (updatedOrder.status === 'ACTIVE' && updatedOrder.hash) { - await cancelHoldInvoice({ hash: updatedOrder.hash }); + try { + await cancelHoldInvoice({ hash: updatedOrder.hash }); + } catch (error) { + logger.error( + `Order Id ${updatedOrder.id}: failed to cancel hold invoice, will retry on next run: ${error}`, + ); + return; // leave the order ACTIVE so the next run retries it + } + // Only reached if the cancellation succeeded. // The seller's sats are no longer in escrow: warn both parties so // the buyer doesn't send fiat off-platform expecting the hold // invoice to still be backing the trade. diff --git a/ln/hold_invoice.ts b/ln/hold_invoice.ts index e7ee94db..cf669bdd 100644 --- a/ln/hold_invoice.ts +++ b/ln/hold_invoice.ts @@ -56,6 +56,9 @@ const cancelHoldInvoice = async ({ hash }: { hash: string }) => { await lightning.cancelHodlInvoice({ lnd, id: hash }); } catch (error) { logger.error(error); + // Callers must not mark an order as canceled/expired if the + // invoice cancellation did not happen. + throw error; } }; From 6257fc5721aaa5f36ebdc1aa67e16968cdfda1a6 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 4 Aug 2026 16:14:33 -0300 Subject: [PATCH 5/7] test: cover ACTIVE hold invoice cancellation branch in cancel_orders job --- tests/jobs/cancel_orders.spec.ts | 195 +++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/jobs/cancel_orders.spec.ts diff --git a/tests/jobs/cancel_orders.spec.ts b/tests/jobs/cancel_orders.spec.ts new file mode 100644 index 00000000..a5b62048 --- /dev/null +++ b/tests/jobs/cancel_orders.spec.ts @@ -0,0 +1,195 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +proxyquire.noCallThru(); + +describe('Job: cancel_orders (ACTIVE hold invoice cancellation branch)', () => { + let sandbox: any; + let cancelOrders: any; + + let orderFindStub: any; + let orderFindByIdStub: any; + let userFindOneStub: any; + let cancelHoldInvoiceStub: any; + let toBuyerHoldInvoiceExpiredMessageStub: any; + let toSellerHoldInvoiceExpiredMessageStub: any; + let orderUpdatedStub: any; + let loggerWarningStub: any; + let loggerErrorStub: any; + let getUserI18nContextStub: any; + + let expiredOrders: any[]; + let updatedOrder: any; + + const buyerUser = { _id: 'buyer1' }; + const sellerUser = { _id: 'seller1' }; + const i18nCtx = { t: (key: string) => key }; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + orderFindByIdStub = sandbox.stub().callsFake(async () => updatedOrder); + + // The job runs three separate Order.find queries; only the "expired + // orders" query (no `$and`, no `admin_warned`) is relevant here. + orderFindStub = sandbox.stub().callsFake(async (query: any) => { + if (query && ('$and' in query || 'admin_warned' in query)) return []; + return expiredOrders; + }); + + userFindOneStub = sandbox.stub().callsFake(async ({ _id }: any) => { + if (_id === 'buyer1') return buyerUser; + if (_id === 'seller1') return sellerUser; + return null; + }); + + cancelHoldInvoiceStub = sandbox.stub().resolves(); + toBuyerHoldInvoiceExpiredMessageStub = sandbox.stub().resolves(); + toSellerHoldInvoiceExpiredMessageStub = sandbox.stub().resolves(); + orderUpdatedStub = sandbox.stub(); + loggerWarningStub = sandbox.stub(); + loggerErrorStub = sandbox.stub(); + getUserI18nContextStub = sandbox.stub().resolves(i18nCtx); + + const jobModule = proxyquire('../../jobs/cancel_orders', { + '../models': { + User: { findOne: userFindOneStub }, + Order: { find: orderFindStub, findById: orderFindByIdStub }, + }, + '../bot/commands': { + cancelShowHoldInvoice: sandbox.stub().resolves(), + cancelAddInvoice: sandbox.stub().resolves(), + }, + '../ln': { cancelHoldInvoice: cancelHoldInvoiceStub }, + '../bot/messages': { + expiredOrderMessage: sandbox.stub().resolves(), + toBuyerExpiredOrderMessage: sandbox.stub().resolves(), + toSellerExpiredOrderMessage: sandbox.stub().resolves(), + toBuyerHoldInvoiceExpiredMessage: toBuyerHoldInvoiceExpiredMessageStub, + toSellerHoldInvoiceExpiredMessage: toSellerHoldInvoiceExpiredMessageStub, + }, + '../util': { + getUserI18nContext: getUserI18nContextStub, + holdInvoiceExpirationInSecs: () => ({ + expirationTimeInSecs: 0, + safetyWindowInSecs: 0, + }), + PerOrderIdMutex: { + instance: { + runExclusive: async (_id: string, cb: () => Promise) => cb(), + }, + }, + }, + '../logger': { + logger: { + error: loggerErrorStub, + warning: loggerWarningStub, + info: sandbox.stub(), + }, + }, + '../bot/modules/events/orders': { orderUpdated: orderUpdatedStub }, + }); + + cancelOrders = jobModule.default; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('marks an ACTIVE order with a hash as EXPIRED when cancelHoldInvoice succeeds', async () => { + updatedOrder = { + _id: 'order1', + id: 'order1', + status: 'ACTIVE', + hash: 'hash1', + buyer_id: 'buyer1', + seller_id: 'seller1', + save: sandbox.stub().resolves(), + }; + expiredOrders = [{ _id: 'order1' }]; + + await cancelOrders({} as any); + + expect(cancelHoldInvoiceStub.calledOnceWith({ hash: 'hash1' })).to.equal( + true, + ); + expect(updatedOrder.status).to.equal('EXPIRED'); + expect(updatedOrder.save.calledOnce).to.equal(true); + expect(toBuyerHoldInvoiceExpiredMessageStub.calledOnce).to.equal(true); + expect(toSellerHoldInvoiceExpiredMessageStub.calledOnce).to.equal(true); + expect(orderUpdatedStub.calledOnceWith(updatedOrder)).to.equal(true); + }); + + it('leaves the order ACTIVE and sends no messages when cancelHoldInvoice throws', async () => { + updatedOrder = { + _id: 'order1', + id: 'order1', + status: 'ACTIVE', + hash: 'hash1', + buyer_id: 'buyer1', + seller_id: 'seller1', + save: sandbox.stub().resolves(), + }; + expiredOrders = [{ _id: 'order1' }]; + cancelHoldInvoiceStub.rejects(new Error('LND error: unable to cancel')); + + await cancelOrders({} as any); + + expect(cancelHoldInvoiceStub.calledOnceWith({ hash: 'hash1' })).to.equal( + true, + ); + expect(updatedOrder.status).to.equal('ACTIVE'); + expect(updatedOrder.save.called).to.equal(false); + expect(toBuyerHoldInvoiceExpiredMessageStub.called).to.equal(false); + expect(toSellerHoldInvoiceExpiredMessageStub.called).to.equal(false); + expect(orderUpdatedStub.called).to.equal(false); + }); + + it('marks an ACTIVE order without a hash as EXPIRED without calling cancelHoldInvoice', async () => { + updatedOrder = { + _id: 'order1', + id: 'order1', + status: 'ACTIVE', + hash: null, + buyer_id: 'buyer1', + seller_id: 'seller1', + save: sandbox.stub().resolves(), + }; + expiredOrders = [{ _id: 'order1' }]; + + await cancelOrders({} as any); + + expect(cancelHoldInvoiceStub.called).to.equal(false); + expect(updatedOrder.status).to.equal('EXPIRED'); + expect(updatedOrder.save.calledOnce).to.equal(true); + expect(orderUpdatedStub.calledOnceWith(updatedOrder)).to.equal(true); + }); + + it('marks a FIAT_SENT order as EXPIRED without calling cancelHoldInvoice, warning instead', async () => { + updatedOrder = { + _id: 'order1', + id: 'order1', + status: 'FIAT_SENT', + hash: 'hash1', + buyer_id: 'buyer1', + seller_id: 'seller1', + save: sandbox.stub().resolves(), + }; + expiredOrders = [{ _id: 'order1' }]; + + await cancelOrders({} as any); + + expect(cancelHoldInvoiceStub.called).to.equal(false); + expect(loggerWarningStub.calledOnce).to.equal(true); + expect(loggerWarningStub.firstCall.args[0]).to.match( + /dispute\/admin handling/, + ); + expect(updatedOrder.status).to.equal('EXPIRED'); + expect(updatedOrder.save.calledOnce).to.equal(true); + expect(orderUpdatedStub.calledOnceWith(updatedOrder)).to.equal(true); + }); +}); + +export {}; From 6ec5fcdb5ca784b047757f3346e65a50e88d0d79 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 4 Aug 2026 16:17:55 -0300 Subject: [PATCH 6/7] style: run prettier on cancel_orders spec --- tests/jobs/cancel_orders.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/jobs/cancel_orders.spec.ts b/tests/jobs/cancel_orders.spec.ts index a5b62048..1fc9117e 100644 --- a/tests/jobs/cancel_orders.spec.ts +++ b/tests/jobs/cancel_orders.spec.ts @@ -67,7 +67,8 @@ describe('Job: cancel_orders (ACTIVE hold invoice cancellation branch)', () => { toBuyerExpiredOrderMessage: sandbox.stub().resolves(), toSellerExpiredOrderMessage: sandbox.stub().resolves(), toBuyerHoldInvoiceExpiredMessage: toBuyerHoldInvoiceExpiredMessageStub, - toSellerHoldInvoiceExpiredMessage: toSellerHoldInvoiceExpiredMessageStub, + toSellerHoldInvoiceExpiredMessage: + toSellerHoldInvoiceExpiredMessageStub, }, '../util': { getUserI18nContext: getUserI18nContextStub, From ea7799f4f2d0dfff94f576c496332ba614cabe06 Mon Sep 17 00:00:00 2001 From: Maxi Vila Date: Tue, 4 Aug 2026 16:29:00 -0300 Subject: [PATCH 7/7] test: cover stale-snapshot re-check guard in cancel_orders mutex --- tests/jobs/cancel_orders.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/jobs/cancel_orders.spec.ts b/tests/jobs/cancel_orders.spec.ts index 1fc9117e..124ab8a0 100644 --- a/tests/jobs/cancel_orders.spec.ts +++ b/tests/jobs/cancel_orders.spec.ts @@ -168,6 +168,29 @@ describe('Job: cancel_orders (ACTIVE hold invoice cancellation branch)', () => { expect(orderUpdatedStub.calledOnceWith(updatedOrder)).to.equal(true); }); + it('does not touch an order that already advanced past ACTIVE/FIAT_SENT by the time the mutex runs', async () => { + // expiredOrders is a stale snapshot taken before the mutex lock; the + // job re-fetches with Order.findById() inside the mutex and must bail + // out if the status changed underneath it (e.g. it was released). + updatedOrder = { + _id: 'order1', + id: 'order1', + status: 'COMPLETED', + hash: 'hash1', + buyer_id: 'buyer1', + seller_id: 'seller1', + save: sandbox.stub().resolves(), + }; + expiredOrders = [{ _id: 'order1' }]; + + await cancelOrders({} as any); + + expect(cancelHoldInvoiceStub.called).to.equal(false); + expect(updatedOrder.status).to.equal('COMPLETED'); + expect(updatedOrder.save.called).to.equal(false); + expect(orderUpdatedStub.called).to.equal(false); + }); + it('marks a FIAT_SENT order as EXPIRED without calling cancelHoldInvoice, warning instead', async () => { updatedOrder = { _id: 'order1',