From 4c40e9d2c75a103da54b0612e4b70da315be0d49 Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 12 Jun 2026 16:49:02 -0300 Subject: [PATCH 1/2] fix: harden authorization checks on freezeorder and paytobuyer - Refuse freezeorder on orders that are not in a freezable state (ACTIVE, FIAT_SENT, DISPUTE), since it settles the hold invoice. - Compare community_id with strict string equality instead of loose ObjectId comparison. - Require community solvers to be the solver assigned to the order's dispute, matching the existing behavior of settleorder/cancelorder. - Refuse solver actions on orders where the solver is the buyer or the seller. Co-Authored-By: Claude Fable 5 --- bot/start.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/bot/start.ts b/bot/start.ts index f9b4a27a..dcfe7b70 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -339,13 +339,43 @@ const initialize = ( if (!order) return; + // freezeorder settles the hold invoice, so it must only run on orders + // that still have funds in escrow. Refuse terminal/paid states. + const freezableStatuses = ['ACTIVE', 'FIAT_SENT', 'DISPUTE']; + if (!freezableStatuses.includes(order.status)) { + logger.warning( + `freezeorder ${order._id}: refused, status ${order.status} is not freezable`, + ); + return await messages.notAuthorized(ctx); + } + + // We look for a dispute for this order + const dispute = await Dispute.findOne({ order_id: order._id }); + // We check if this is a solver, the order must be from the same community if (!ctx.admin.admin) { if (!order.community_id) { return await messages.notAuthorized(ctx); } - if (order.community_id != ctx.admin.default_community_id) { + if (String(order.community_id) !== String(ctx.admin.default_community_id)) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a community solver may only act on the dispute they were + // assigned to (parity with settleorder/cancelorder). + if (dispute && String(dispute.solver_id) !== String(ctx.admin._id)) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a solver must never resolve an order they are a party to. + if ( + String(order.buyer_id) === String(ctx.admin._id) || + String(order.seller_id) === String(ctx.admin._id) + ) { + logger.warning( + `freezeorder ${order._id}: @${ctx.admin.username} is a party to this order`, + ); return await messages.notAuthorized(ctx); } } @@ -1002,13 +1032,34 @@ const initialize = ( return await ctx.reply(ctx.i18n.t('paytobuyer_only_frozen_orders')); } + // We look for a dispute for this order + const dispute = await Dispute.findOne({ order_id: order._id }); + // We check if this is a solver, the order must be from the same community if (!ctx.admin.admin) { if (!order.community_id) { return await messages.notAuthorized(ctx); } - if (order.community_id != ctx.admin.default_community_id) { + if (String(order.community_id) !== String(ctx.admin.default_community_id)) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a community solver may only act on the dispute they were + // assigned to (parity with settleorder/cancelorder). + if (dispute && String(dispute.solver_id) !== String(ctx.admin._id)) { + return await messages.notAuthorized(ctx); + } + + // SECURITY: a solver must never push the payout of an order they are + // a party to. + if ( + String(order.buyer_id) === String(ctx.admin._id) || + String(order.seller_id) === String(ctx.admin._id) + ) { + logger.warning( + `paytobuyer ${order._id}: @${ctx.admin.username} is a party to this order`, + ); return await messages.notAuthorized(ctx); } } From b1cc40ae8340fb868e898876d20da89e827f50ff Mon Sep 17 00:00:00 2001 From: grunch Date: Fri, 12 Jun 2026 18:03:27 -0300 Subject: [PATCH 2/2] fix: persist FROZEN status only after hold invoice settlement succeeds freezeorder saved order.status = 'FROZEN' before calling settleHoldInvoice, and the helper swallowed LND errors, so a failed settlement left the order stuck in FROZEN with the invoice still held. Settle first and save after, and make settleHoldInvoice rethrow after logging so callers can abort their state changes. All call sites (freezeorder, settleorder, release) already run inside try/catch. Also fixes prettier formatting that broke CI. Co-Authored-By: Claude Fable 5 --- bot/start.ts | 14 ++++++++++---- ln/hold_invoice.ts | 3 +++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bot/start.ts b/bot/start.ts index dcfe7b70..43467845 100644 --- a/bot/start.ts +++ b/bot/start.ts @@ -358,7 +358,9 @@ const initialize = ( return await messages.notAuthorized(ctx); } - if (String(order.community_id) !== String(ctx.admin.default_community_id)) { + if ( + String(order.community_id) !== String(ctx.admin.default_community_id) + ) { return await messages.notAuthorized(ctx); } @@ -380,13 +382,15 @@ const initialize = ( } } + // Settle the hold invoice first; only persist the FROZEN status + // if the settlement succeeded. + if (order.secret) await settleHoldInvoice({ secret: order.secret }); + order.is_frozen = true; order.status = 'FROZEN'; order.action_by = ctx.admin._id; await order.save(); - if (order.secret) await settleHoldInvoice({ secret: order.secret }); - await ctx.reply(ctx.i18n.t('order_frozen')); } catch (error) { logger.error(error); @@ -1041,7 +1045,9 @@ const initialize = ( return await messages.notAuthorized(ctx); } - if (String(order.community_id) !== String(ctx.admin.default_community_id)) { + if ( + String(order.community_id) !== String(ctx.admin.default_community_id) + ) { return await messages.notAuthorized(ctx); } diff --git a/ln/hold_invoice.ts b/ln/hold_invoice.ts index 0bdf51de..e7ee94db 100644 --- a/ln/hold_invoice.ts +++ b/ln/hold_invoice.ts @@ -45,6 +45,9 @@ const settleHoldInvoice = async ({ secret }: { secret: string }) => { await lightning.settleHodlInvoice({ lnd, secret }); } catch (error) { logger.error(error); + // Callers must not mark an order as settled/frozen if the + // invoice settlement did not happen. + throw error; } };