From dfe7aa652b55415ba2264a4dfbbc03a6365acd0d Mon Sep 17 00:00:00 2001 From: Tori Date: Thu, 9 Apr 2026 11:19:33 -0500 Subject: [PATCH 1/8] feat: enforce min/max sats amount on orders (#406) Add configurable MAX_PAYMENT_AMT environment variable alongside the existing MIN_PAYMENT_AMT to enforce sats amount limits on orders. Changes: - Add MAX_PAYMENT_AMT to .env-sample (default: 10000000) - Add max validation to validateSellOrder and validateBuyOrder - Add min/max validation to the interactive wizard (scenes.ts) - Add mustBeLessEqThan message helper in messages.ts - Add must_be_lt_or_eq locale key in all 10 languages - Add tests for max exceeded, equal to max, boundary, and market price (amount=0) edge cases The max check is optional: if MAX_PAYMENT_AMT is not set, no upper limit is enforced (backwards compatible). Amount 0 (market price orders) always bypasses both min and max checks. Closes #406 --- .env-sample | 2 + bot/messages.ts | 18 +++++ bot/modules/orders/scenes.ts | 18 +++++ bot/validations.ts | 13 +++- locales/de.yaml | 1 + locales/en.yaml | 1 + locales/es.yaml | 1 + locales/fa.yaml | 1 + locales/fr.yaml | 1 + locales/it.yaml | 1 + locales/ko.yaml | 1 + locales/pt.yaml | 1 + locales/ru.yaml | 1 + locales/uk.yaml | 1 + tests/bot/validation.spec.ts | 124 +++++++++++++++++++++++++++++++++++ 15 files changed, 184 insertions(+), 1 deletion(-) diff --git a/.env-sample b/.env-sample index dc9dcafc..744aa63b 100644 --- a/.env-sample +++ b/.env-sample @@ -51,6 +51,8 @@ ORDER_PUBLISHED_EXPIRATION_WINDOW=82800 # Minimum amount for a payment in satoshis MIN_PAYMENT_AMT=1 +# Maximum amount for a payment in satoshis +MAX_PAYMENT_AMT=10000000 # Maximum number of orders that a user can have published (PENDING) at the same time MAX_PENDING_ORDERS=4 diff --git a/bot/messages.ts b/bot/messages.ts index 0aacf627..0ff0bac5 100644 --- a/bot/messages.ts +++ b/bot/messages.ts @@ -960,6 +960,23 @@ const mustBeGreatherEqThan = async ( } }; +const mustBeLessEqThan = async ( + ctx: MainContext, + fieldName: string, + qty: number, +) => { + try { + await ctx.reply( + ctx.i18n.t('must_be_lt_or_eq', { + fieldName, + qty, + }), + ); + } catch (error) { + logger.error(error); + } +}; + const bannedUserErrorMessage = async (ctx: MainContext, user: UserDocument) => { try { await ctx.telegram.sendMessage( @@ -2258,6 +2275,7 @@ export { termsMessage, privacyMessage, mustBeGreatherEqThan, + mustBeLessEqThan, bannedUserErrorMessage, fiatSentMessages, orderOnfiatSentStatusMessages, diff --git a/bot/modules/orders/scenes.ts b/bot/modules/orders/scenes.ts index d66823d5..0703075b 100644 --- a/bot/modules/orders/scenes.ts +++ b/bot/modules/orders/scenes.ts @@ -448,6 +448,24 @@ const createOrderHandlers = { await ctx.wizard.state.updateUI(); return; } + const minPaymentAmt = Number(process.env.MIN_PAYMENT_AMT) || 0; + const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT) || 0; + if (input !== 0 && minPaymentAmt > 0 && input < minPaymentAmt) { + ctx.wizard.state.error = ctx.i18n.t('must_be_gt_or_eq', { + fieldName: ctx.i18n.t('sats_amount'), + qty: minPaymentAmt, + }); + await ctx.wizard.state.updateUI(); + return; + } + if (input !== 0 && maxPaymentAmt > 0 && input > maxPaymentAmt) { + ctx.wizard.state.error = ctx.i18n.t('must_be_lt_or_eq', { + fieldName: ctx.i18n.t('sats_amount'), + qty: maxPaymentAmt, + }); + await ctx.wizard.state.updateUI(); + return; + } ctx.wizard.state.sats = Math.floor(input); await ctx.wizard.state.updateUI(); return true; diff --git a/bot/validations.ts b/bot/validations.ts index 045eb3f4..5b95b26e 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -206,7 +206,6 @@ const validateSellOrder = async (ctx: MainContext) => { return false; } - // TODO, this validation could be amount > 0? if (amount !== 0 && amount < Number(process.env.MIN_PAYMENT_AMT)) { await messages.mustBeGreatherEqThan( ctx, @@ -216,6 +215,12 @@ const validateSellOrder = async (ctx: MainContext) => { return false; } + const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT) || 0; + if (amount !== 0 && maxPaymentAmt > 0 && amount > maxPaymentAmt) { + await messages.mustBeLessEqThan(ctx, 'monto_en_sats', maxPaymentAmt); + return false; + } + if (fiatAmount.length === 2 && fiatAmount[1] <= fiatAmount[0]) { await messages.mustBeANumberOrRange(ctx); return false; @@ -304,6 +309,12 @@ const validateBuyOrder = async (ctx: MainContext) => { return false; } + const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT) || 0; + if (amount !== 0 && maxPaymentAmt > 0 && amount > maxPaymentAmt) { + await messages.mustBeLessEqThan(ctx, 'monto_en_sats', maxPaymentAmt); + return false; + } + if (fiatAmount.length === 2 && fiatAmount[1] <= fiatAmount[0]) { await messages.mustBeANumberOrRange(ctx); return false; diff --git a/locales/de.yaml b/locales/de.yaml index 65474515..be7dcab6 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -286,6 +286,7 @@ help: | /version - Zeigt die aktuelle Version des Bots /help - Hilfe must_be_gt_or_eq: ${fieldName} muss ${qty} oder mehr entsprechen +must_be_lt_or_eq: ${fieldName} muss ${qty} oder weniger entsprechen you_have_been_banned: Du wurdest gesperrt! I_told_seller_you_sent_fiat: 🤖 Ich habe @${sellerUsername} gesagt, dass du FIAT-Geld geschickt hast. Wenn der Verkäufer bestätigt, dass er dein Geld erhalten hat, muss er die Mittel freigeben. Falls er sich weigert, kannst du eine Streitigkeit eröffnen. buyer_told_me_that_sent_fiat: | diff --git a/locales/en.yaml b/locales/en.yaml index fe6c5aec..f499d971 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -292,6 +292,7 @@ help: | version: Version commit_hash: Hash of last commit must_be_gt_or_eq: ${fieldName} Must be greater or equal to ${qty} +must_be_lt_or_eq: ${fieldName} Must be less or equal to ${qty} you_have_been_banned: You have been banned! I_told_seller_you_sent_fiat: 🤖 I told @${sellerUsername} that you have sent the fiat money. When the seller confirms that they have received your money, they should release the funds. If they refuse, you can open a dispute. buyer_told_me_that_sent_fiat: | diff --git a/locales/es.yaml b/locales/es.yaml index 62cd4029..3377b8c7 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -290,6 +290,7 @@ help: | version: Versión commit_hash: Hash del último commit must_be_gt_or_eq: ${fieldName} debe ser mayor o igual que ${qty} +must_be_lt_or_eq: ${fieldName} debe ser menor o igual que ${qty} you_have_been_banned: ¡Has sido baneado! I_told_seller_you_sent_fiat: 🤖 Le avisé a @${sellerUsername} que has enviado el dinero fiat, cuando el vendedor confirme que recibió tu dinero deberá liberar los fondos. Si se niega, puedes abrir una disputa. buyer_told_me_that_sent_fiat: | diff --git a/locales/fa.yaml b/locales/fa.yaml index acf196be..0b596aba 100644 --- a/locales/fa.yaml +++ b/locales/fa.yaml @@ -370,6 +370,7 @@ help: | version: نسخه commit_hash: هش آخرین پرداخت وثیقه must_be_gt_or_eq: '${fieldName} باید بزرگ‌تر یا برابر با ${qty} باشد.' +must_be_lt_or_eq: '${fieldName} باید کوچک‌تر یا برابر با ${qty} باشد.' you_have_been_banned: 'شما محروم شده‌اید!' I_told_seller_you_sent_fiat: '🤖 من به @${sellerUsername} خبر دادم که شما پول فیات را فرستاده‌اید. فروشنده باید ساتوشی‌ها را پس از بررسی اینکه پول شما را دریافت کرده است، آزاد کند. اگر او این کار را نکرد، می‌توانید یک مشاجره ثبت کنید.' buyer_told_me_that_sent_fiat: | diff --git a/locales/fr.yaml b/locales/fr.yaml index df82ee9c..82d6dfc7 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -288,6 +288,7 @@ help: | /version - Affiche la version actuelle du bot /help - Messages d'aide must_be_gt_or_eq: ${fieldName} Doit être supérieur ou égal à ${qty} +must_be_lt_or_eq: ${fieldName} Doit être inférieur ou égal à ${qty} you_have_been_banned: Tu as été banni ! I_told_seller_you_sent_fiat: "🤖 J'ai dit à @${sellerUsername} que tu as envoyé le paiement fiat. Lorsque le vendeur confirmera avoir reçu ton argent, il devra libérer les fonds. S'il refuse, tu peux ouvrir un litige." buyer_told_me_that_sent_fiat: | diff --git a/locales/it.yaml b/locales/it.yaml index 85359643..2b7d6fc8 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -286,6 +286,7 @@ help: | /version - mostra la versione corrente del bot /help - messaggi di aiuto must_be_gt_or_eq: ${fieldName} Deve essere superiore o uguale a ${qty} +must_be_lt_or_eq: ${fieldName} Deve essere inferiore o uguale a ${qty} you_have_been_banned: Sei stato bannato! I_told_seller_you_sent_fiat: 🤖 Ho avvisato @${sellerUsername} che hai inviato il denaro fiat, quando il venditore confermerà di aver ricevuto il tuo denaro dovrà liberare i fondi. Se si rifiuta, puoi aprire una disputa. buyer_told_me_that_sent_fiat: | diff --git a/locales/ko.yaml b/locales/ko.yaml index 24fe08a5..cd864b66 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -287,6 +287,7 @@ help: | /version - 봇의 현재 버전을 보여줍니다. /help - 도움말을 보여줍니다. must_be_gt_or_eq: ${fieldName}은 최소 ${qty}보다 크거나 같아야 합니다. +must_be_lt_or_eq: ${fieldName}은 ${qty}보다 작거나 같아야 합니다. you_have_been_banned: 당신은 추방되었습니다! I_told_seller_you_sent_fiat: 🤖 @${sellerUsername} 에게 당신이 fiat를 송금했다고 알렸습니다. 판매자가 돈을 받았다고 확인하면 자금을 해제해야 합니다. 만약 거부하면 분쟁을 열 수 있습니다. buyer_told_me_that_sent_fiat: | diff --git a/locales/pt.yaml b/locales/pt.yaml index 274ea54e..e7fa02c1 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -287,6 +287,7 @@ help: | /version - mostra a versão atual do bot /help - mensagem de ajuda must_be_gt_or_eq: ${fieldName} Deve ser mais ou igual a ${qty} +must_be_lt_or_eq: ${fieldName} Deve ser menor ou igual a ${qty} you_have_been_banned: Você foi banido! I_told_seller_you_sent_fiat: 🤖 Informei a @${sellerUsername} que você enviou o dinheiro fiat, quando o vendedor confirmar que recebeu seu dinheiro, ele deverá liberar os fundos. Se ele se recusar, você pode abrir uma disputa. buyer_told_me_that_sent_fiat: | diff --git a/locales/ru.yaml b/locales/ru.yaml index 49b61552..612636c1 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -285,6 +285,7 @@ help: | /version - Показывает текущую версию бота /help - Показать ключевые команды must_be_gt_or_eq: ${fieldName} должно быть больше или равно ${qty} +must_be_lt_or_eq: ${fieldName} должно быть меньше или равно ${qty} you_have_been_banned: Вы были забанены! I_told_seller_you_sent_fiat: 🤖 Я сообщил @${sellerUsername}, что ты отправил фиат, когда продавец подтвердит получение денег, он должен освободить средства. Если он откажется, можешь открыть спор. buyer_told_me_that_sent_fiat: | diff --git a/locales/uk.yaml b/locales/uk.yaml index bda2aa09..83beff73 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -285,6 +285,7 @@ help: | /version - Показує поточну версію бота /help - Показати ключові команди must_be_gt_or_eq: ${fieldName} має бути більше чи рівно ${qty} +must_be_lt_or_eq: ${fieldName} має бути менше чи рівно ${qty} you_have_been_banned: Ви були забанені! I_told_seller_you_sent_fiat: 🤖 Я повідомив @${sellerUsername}, що ти надіслав фіат, коли продавець підтвердить отримання твоїх грошей, він звільнить кошти. Якщо він відмовиться, ти можеш відкрити спір. buyer_told_me_that_sent_fiat: | diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index 63a5f3a6..fe3e33f5 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -143,6 +143,71 @@ describe('Validations', () => { expect(replyStub.calledOnce).to.equal(true); }); + it('should return false if amount exceeds maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['6000', '100', 'USD', 'zelle']; + const result = await validateSellOrder(ctx); + expect(result).to.equal(false); + expect(replyStub.calledOnce).to.equal(true); + }); + + it('should allow amount equal to maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['5000', '100', 'USD', 'zelle']; + const result = await validateSellOrder(ctx); + expect(result).to.be.an('object'); + }); + + it('should skip max check when MAX_PAYMENT_AMT is not set', async () => { + ctx.state.command.args = ['10000', '100', 'USD', 'zelle']; + const result = await validateSellOrder(ctx); + expect(result).to.be.an('object'); + }); + + it('should allow amount 0 (market price) even with max set', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['0', '100-200', 'USD', 'zelle']; + const result = await validateSellOrder(ctx); + expect(result).to.be.an('object'); + if (result === false) throw new Error('object expected'); + expect(result.amount).to.equal(0); + }); + + it('should return false if amount is exactly one above maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['5001', '100', 'USD', 'zelle']; + const result = await validateSellOrder(ctx); + expect(result).to.equal(false); + }); + it('should return object if validation success', async () => { ctx.state.command.args = ['10000', '100', 'USD', 'zelle']; const result = await validateSellOrder(ctx); @@ -217,6 +282,65 @@ describe('Validations', () => { expect(replyStub.calledOnce).to.be.equal(true); }); + it('should return false if amount exceeds maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['6000', '100', 'USD', 'zelle']; + const result = await validateBuyOrder(ctx); + expect(result).to.equal(false); + expect(replyStub.calledOnce).to.equal(true); + }); + + it('should allow amount equal to maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['5000', '100', 'USD', 'zelle']; + const result = await validateBuyOrder(ctx); + expect(result).to.be.an('object'); + }); + + it('should allow amount 0 (market price) even with max set', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['0', '100-200', 'USD', 'zelle']; + const result = await validateBuyOrder(ctx); + expect(result).to.be.an('object'); + if (result === false) throw new Error('object expected'); + expect(result.amount).to.equal(0); + }); + + it('should return false if amount is exactly one above maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + ctx.state.command.args = ['5001', '100', 'USD', 'zelle']; + const result = await validateBuyOrder(ctx); + expect(result).to.equal(false); + }); + it('should return object if validation success', async () => { ctx.state.command.args = ['10000', '100', 'USD', 'zelle']; const result = await validateBuyOrder(ctx); From f3f51f55e727bfe6ea96ea5e24f359bd86c994a3 Mon Sep 17 00:00:00 2001 From: Tori Date: Mon, 4 May 2026 22:34:00 -0500 Subject: [PATCH 2/8] fix: normalize sats input before min/max validation in wizard Ensure Math.floor is applied before comparing against MIN/MAX_PAYMENT_AMT so the value checked matches the value stored in wizard state. Also export createOrderHandlers and add wizard-path tests for min/max bounds. --- bot/modules/orders/scenes.ts | 11 ++-- tests/bot/validation.spec.ts | 101 +++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/bot/modules/orders/scenes.ts b/bot/modules/orders/scenes.ts index 0703075b..450cc1ae 100644 --- a/bot/modules/orders/scenes.ts +++ b/bot/modules/orders/scenes.ts @@ -392,7 +392,7 @@ const createOrderPrompts = { }, }; -const createOrderHandlers = { +export const createOrderHandlers = { async fiatAmount(ctx: CommunityContext) { if (ctx.message === undefined) return ctx.scene.leave(); ctx.wizard.state.error = null; @@ -436,18 +436,19 @@ const createOrderHandlers = { await ctx.wizard.state.updateUI(); return true; } - const input = Number(ctx.message?.text); + const rawInput = Number(ctx.message?.text); await ctx.deleteMessage(); - if (isNaN(input)) { + if (isNaN(rawInput)) { ctx.wizard.state.error = ctx.i18n.t('not_number'); await ctx.wizard.state.updateUI(); return; } - if (input < 0) { + if (rawInput < 0) { ctx.wizard.state.error = ctx.i18n.t('not_negative'); await ctx.wizard.state.updateUI(); return; } + const input = Math.floor(rawInput); const minPaymentAmt = Number(process.env.MIN_PAYMENT_AMT) || 0; const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT) || 0; if (input !== 0 && minPaymentAmt > 0 && input < minPaymentAmt) { @@ -466,7 +467,7 @@ const createOrderHandlers = { await ctx.wizard.state.updateUI(); return; } - ctx.wizard.state.sats = Math.floor(input); + ctx.wizard.state.sats = input; await ctx.wizard.state.updateUI(); return true; }, diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index fe3e33f5..606b2682 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -12,6 +12,7 @@ import { validateUserWaitingOrder, isBannedFromCommunity, } from '../../bot/validations'; +import { createOrderHandlers } from '../../bot/modules/orders/scenes'; import * as messages from '../../bot/messages'; import { Order, User, Community } from '../../models'; import { IOrder } from '../../models/order'; @@ -1225,6 +1226,106 @@ describe('Validations', () => { }); }); + describe('createOrderHandlers.sats (wizard path)', () => { + let wizardCtx: any; + + beforeEach(() => { + wizardCtx = { + callbackQuery: undefined, + message: { text: '1000' }, + i18n: { + t: (key: string, _params?: any) => key, + }, + wizard: { + state: { + sats: undefined, + error: undefined, + updateUI: sinon.stub().resolves(), + }, + }, + deleteMessage: sinon.stub().resolves(), + }; + }); + + it('should set error when amount exceeds maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'test', + }); + wizardCtx.message.text = '6000'; + const result = await createOrderHandlers.sats(wizardCtx); + expect(result).to.equal(undefined); + expect(wizardCtx.wizard.state.error).to.equal('must_be_lt_or_eq'); + expect(wizardCtx.wizard.state.updateUI.calledOnce).to.equal(true); + }); + + it('should allow amount equal to maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'test', + }); + wizardCtx.message.text = '5000'; + const result = await createOrderHandlers.sats(wizardCtx); + expect(result).to.equal(true); + expect(wizardCtx.wizard.state.sats).to.equal(5000); + }); + + it('should skip max check when MAX_PAYMENT_AMT is not set', async () => { + wizardCtx.message.text = '99999'; + const result = await createOrderHandlers.sats(wizardCtx); + expect(result).to.equal(true); + expect(wizardCtx.wizard.state.sats).to.equal(99999); + }); + + it('should allow amount 0 (market price) even with max set', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'test', + }); + wizardCtx.message.text = '0'; + const result = await createOrderHandlers.sats(wizardCtx); + expect(result).to.equal(true); + expect(wizardCtx.wizard.state.sats).to.equal(0); + }); + + it('should reject amount one above maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'test', + }); + wizardCtx.message.text = '5001'; + const result = await createOrderHandlers.sats(wizardCtx); + expect(result).to.equal(undefined); + expect(wizardCtx.wizard.state.error).to.equal('must_be_lt_or_eq'); + }); + + it('should accept decimal that floors to exactly the maximum', async () => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'test', + }); + wizardCtx.message.text = '5000.9'; + const result = await createOrderHandlers.sats(wizardCtx); + expect(result).to.equal(true); + expect(wizardCtx.wizard.state.sats).to.equal(5000); + }); + }); + describe('isBannedFromCommunity', () => { beforeEach(() => { community = { From 05ca3bc0fa0fef743a8fce2603c28395cade6d19 Mon Sep 17 00:00:00 2001 From: Tori Date: Mon, 4 May 2026 22:59:51 -0500 Subject: [PATCH 3/8] fix: use Number.isFinite for MAX_PAYMENT_AMT guard in validations Replace the truthy-string check with Number.isFinite so NaN and Infinity are explicitly handled. Also clarify in .env-sample that MAX_PAYMENT_AMT is optional and leaving it unset disables the upper bound. --- .env-sample | 2 +- bot/validations.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.env-sample b/.env-sample index 744aa63b..6055b7eb 100644 --- a/.env-sample +++ b/.env-sample @@ -51,7 +51,7 @@ ORDER_PUBLISHED_EXPIRATION_WINDOW=82800 # Minimum amount for a payment in satoshis MIN_PAYMENT_AMT=1 -# Maximum amount for a payment in satoshis +# Maximum amount for a payment in satoshis (optional, in satoshis). Leave unset or set to 0 to disable the upper bound. MAX_PAYMENT_AMT=10000000 # Maximum number of orders that a user can have published (PENDING) at the same time diff --git a/bot/validations.ts b/bot/validations.ts index 5b95b26e..a139b959 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -215,8 +215,8 @@ const validateSellOrder = async (ctx: MainContext) => { return false; } - const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT) || 0; - if (amount !== 0 && maxPaymentAmt > 0 && amount > maxPaymentAmt) { + const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT); + if (amount !== 0 && Number.isFinite(maxPaymentAmt) && maxPaymentAmt > 0 && amount > maxPaymentAmt) { await messages.mustBeLessEqThan(ctx, 'monto_en_sats', maxPaymentAmt); return false; } @@ -309,8 +309,8 @@ const validateBuyOrder = async (ctx: MainContext) => { return false; } - const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT) || 0; - if (amount !== 0 && maxPaymentAmt > 0 && amount > maxPaymentAmt) { + const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT); + if (amount !== 0 && Number.isFinite(maxPaymentAmt) && maxPaymentAmt > 0 && amount > maxPaymentAmt) { await messages.mustBeLessEqThan(ctx, 'monto_en_sats', maxPaymentAmt); return false; } From a07bb88e2212e645a210969625dae1862bc4a7ea Mon Sep 17 00:00:00 2001 From: Tori Date: Wed, 13 May 2026 23:10:09 -0500 Subject: [PATCH 4/8] fix: guard ctx.message before deleteMessage in sats wizard step --- bot/modules/orders/scenes.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/modules/orders/scenes.ts b/bot/modules/orders/scenes.ts index 450cc1ae..8075ebcc 100644 --- a/bot/modules/orders/scenes.ts +++ b/bot/modules/orders/scenes.ts @@ -436,7 +436,10 @@ export const createOrderHandlers = { await ctx.wizard.state.updateUI(); return true; } - const rawInput = Number(ctx.message?.text); + if (!ctx.message?.text) { + return ctx.scene.leave(); + } + const rawInput = Number(ctx.message.text); await ctx.deleteMessage(); if (isNaN(rawInput)) { ctx.wizard.state.error = ctx.i18n.t('not_number'); From 070ecc9c194bae154b2dc8879cb4716cbd499f27 Mon Sep 17 00:00:00 2001 From: Tori Date: Thu, 14 May 2026 00:28:43 -0500 Subject: [PATCH 5/8] fix: use i18n keys instead of hardcoded Spanish strings for field names --- bot/validations.ts | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/bot/validations.ts b/bot/validations.ts index a139b959..5c13cdf1 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -209,15 +209,24 @@ const validateSellOrder = async (ctx: MainContext) => { if (amount !== 0 && amount < Number(process.env.MIN_PAYMENT_AMT)) { await messages.mustBeGreatherEqThan( ctx, - 'monto_en_sats', + ctx.i18n.t('sats_amount'), Number(process.env.MIN_PAYMENT_AMT), ); return false; } const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT); - if (amount !== 0 && Number.isFinite(maxPaymentAmt) && maxPaymentAmt > 0 && amount > maxPaymentAmt) { - await messages.mustBeLessEqThan(ctx, 'monto_en_sats', maxPaymentAmt); + if ( + amount !== 0 && + Number.isFinite(maxPaymentAmt) && + maxPaymentAmt > 0 && + amount > maxPaymentAmt + ) { + await messages.mustBeLessEqThan( + ctx, + ctx.i18n.t('sats_amount'), + maxPaymentAmt, + ); return false; } @@ -232,7 +241,7 @@ const validateSellOrder = async (ctx: MainContext) => { } if (fiatAmount.some((x: number) => x < 1)) { - await messages.mustBeGreatherEqThan(ctx, 'monto_en_fiat', 1); + await messages.mustBeGreatherEqThan(ctx, ctx.i18n.t('fiat_amount'), 1); return false; } @@ -303,15 +312,24 @@ const validateBuyOrder = async (ctx: MainContext) => { if (amount !== 0 && amount < Number(process.env.MIN_PAYMENT_AMT)) { await messages.mustBeGreatherEqThan( ctx, - 'monto_en_sats', + ctx.i18n.t('sats_amount'), Number(process.env.MIN_PAYMENT_AMT), ); return false; } const maxPaymentAmt = Number(process.env.MAX_PAYMENT_AMT); - if (amount !== 0 && Number.isFinite(maxPaymentAmt) && maxPaymentAmt > 0 && amount > maxPaymentAmt) { - await messages.mustBeLessEqThan(ctx, 'monto_en_sats', maxPaymentAmt); + if ( + amount !== 0 && + Number.isFinite(maxPaymentAmt) && + maxPaymentAmt > 0 && + amount > maxPaymentAmt + ) { + await messages.mustBeLessEqThan( + ctx, + ctx.i18n.t('sats_amount'), + maxPaymentAmt, + ); return false; } @@ -326,7 +344,7 @@ const validateBuyOrder = async (ctx: MainContext) => { } if (fiatAmount.some((x: number) => x < 1)) { - await messages.mustBeGreatherEqThan(ctx, 'monto_en_fiat', 1); + await messages.mustBeGreatherEqThan(ctx, ctx.i18n.t('fiat_amount'), 1); return false; } From 190c703e9b5bd2e3705d8a7885fb63d59f229cc3 Mon Sep 17 00:00:00 2001 From: Tori Date: Wed, 10 Jun 2026 23:24:34 -0500 Subject: [PATCH 6/8] feat: enforce min/max sats on market price orders via live estimate Market price orders (amount === 0) bypassed the MIN/MAX_PAYMENT_AMT checks because their sats are only known at take time. Estimate the sats at the current market price (same formula as take time, incl. price margin) and validate the estimate. For ranges, the lower bound is checked against MIN and the upper against MAX. If the price oracle is unavailable the order is allowed through. Covers the command path (validateSellOrder/validateBuyOrder) and the order wizard. --- bot/modules/community/communityContext.ts | 2 +- bot/modules/orders/scenes.ts | 31 ++++- bot/validations.ts | 63 +++++++++ tests/bot/validation.spec.ts | 162 ++++++++++++++++++++++ util/index.ts | 44 ++++++ 5 files changed, 300 insertions(+), 2 deletions(-) diff --git a/bot/modules/community/communityContext.ts b/bot/modules/community/communityContext.ts index 62cfa3ac..4f788b60 100644 --- a/bot/modules/community/communityContext.ts +++ b/bot/modules/community/communityContext.ts @@ -32,7 +32,7 @@ export interface CommunityWizardState { channels: IOrderChannel[]; fee: number; sats: number; - fiatAmount: number[]; + fiatAmount?: number[]; priceMargin: number; solvers: IUsernameId[]; disputeChannel: any; diff --git a/bot/modules/orders/scenes.ts b/bot/modules/orders/scenes.ts index 8075ebcc..71a0e64e 100644 --- a/bot/modules/orders/scenes.ts +++ b/bot/modules/orders/scenes.ts @@ -1,6 +1,6 @@ import { Scenes, Markup } from 'telegraf'; import { logger } from '../../../logger'; -import { getCurrency } from '../../../util'; +import { getCurrency, checkMarketOrderSatsLimits } from '../../../util'; import { Community } from '../../../models'; import * as ordersActions from '../../ordersActions'; import { @@ -67,6 +67,35 @@ export const createOrder = new Scenes.WizardScene( return createOrderSteps.priceMargin(ctx); if (undefined === method) return createOrderSteps.method(ctx); + // Market price orders (sats === 0) settle in sats at take time. Estimate + // the sats at the current market price and enforce MIN/MAX on the + // estimate before creating the order. Covers both the range path and the + // "market price" button, since both reach this gate with sats === 0. + if (sats === 0) { + const check = await checkMarketOrderSatsLimits( + currency, + fiatAmount, + priceMargin ?? 0, + ); + if (check.status === 'below_min' || check.status === 'above_max') { + ctx.wizard.state.error = ctx.i18n.t( + check.status === 'below_min' + ? 'must_be_gt_or_eq' + : 'must_be_lt_or_eq', + { fieldName: ctx.i18n.t('sats_amount'), qty: check.limit }, + ); + ctx.wizard.state.fiatAmount = undefined; + await ctx.wizard.state.updateUI(); + return createOrderSteps.fiatAmount(ctx); + } + if (check.status === 'price_unavailable') { + logger.warning( + 'Market price order sats estimate skipped: price API unavailable', + ); + } + } + + // We remove all special characters from the payment method(s) const replaceRegex = /[&/\\#,+~%.'":*?<>{}]/g; const paymentMethod = selectedMethods?.length ? selectedMethods.map(m => m.replace(replaceRegex, '')).join(', ') diff --git a/bot/validations.ts b/bot/validations.ts index 5c13cdf1..7ef091f9 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -17,6 +17,7 @@ import { isDisputeSolver, removeLightningPrefix, isOrderCreator, + checkMarketOrderSatsLimits, } from '../util'; import { existLightningAddress } from '../lnurl/lnurl-pay'; import { logger } from '../logger'; @@ -250,6 +251,37 @@ const validateSellOrder = async (ctx: MainContext) => { return false; } + // Market price orders (amount === 0) settle in sats at take time. Estimate + // the sats at the current market price and enforce MIN/MAX on the estimate. + if (amount === 0) { + const check = await checkMarketOrderSatsLimits( + fiatCode.toUpperCase(), + fiatAmount, + Number(priceMargin) || 0, + ); + if (check.status === 'below_min') { + await messages.mustBeGreatherEqThan( + ctx, + ctx.i18n.t('sats_amount'), + check.limit, + ); + return false; + } + if (check.status === 'above_max') { + await messages.mustBeLessEqThan( + ctx, + ctx.i18n.t('sats_amount'), + check.limit, + ); + return false; + } + if (check.status === 'price_unavailable') { + logger.warning( + 'Market price order sats estimate skipped: price API unavailable', + ); + } + } + paymentMethod = paymentMethod.replace(/[&/\\#,+~%.'":*?<>{}]/g, ''); return { @@ -353,6 +385,37 @@ const validateBuyOrder = async (ctx: MainContext) => { return false; } + // Market price orders (amount === 0) settle in sats at take time. Estimate + // the sats at the current market price and enforce MIN/MAX on the estimate. + if (amount === 0) { + const check = await checkMarketOrderSatsLimits( + fiatCode.toUpperCase(), + fiatAmount, + Number(priceMargin) || 0, + ); + if (check.status === 'below_min') { + await messages.mustBeGreatherEqThan( + ctx, + ctx.i18n.t('sats_amount'), + check.limit, + ); + return false; + } + if (check.status === 'above_max') { + await messages.mustBeLessEqThan( + ctx, + ctx.i18n.t('sats_amount'), + check.limit, + ); + return false; + } + if (check.status === 'price_unavailable') { + logger.warning( + 'Market price order sats estimate skipped: price API unavailable', + ); + } + } + paymentMethod = paymentMethod.replace(/[&/\\#,+~%.'":*?<>{}]/g, ''); return { diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index 606b2682..8b8073fd 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -1367,4 +1367,166 @@ describe('Validations', () => { expect(result).to.equal(true); }); }); + + describe('checkMarketOrderSatsLimits (market price estimate)', () => { + let axiosGet: any; + let checkMarketOrderSatsLimits: any; + + beforeEach(() => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + FIAT_RATE_EP: 'http://fake-oracle', + NODE_ENV: 'test', + }); + axiosGet = sinon.stub(); + // 1 BTC = 1e8 fiat => estimated sats == fiat amount + axiosGet.resolves({ data: { btc: 1e8 } }); + checkMarketOrderSatsLimits = proxyquire('../../util', { + axios: { default: { get: axiosGet }, __esModule: true }, + }).checkMarketOrderSatsLimits; + }); + + it('rejects (below_min) when estimated sats are under MIN', async () => { + const res = await checkMarketOrderSatsLimits('USD', [50], 0); + expect(res.status).to.equal('below_min'); + expect(res.limit).to.equal(100); + }); + + it('accepts (ok) when estimated sats are within limits', async () => { + const res = await checkMarketOrderSatsLimits('USD', [100], 0); + expect(res.status).to.equal('ok'); + }); + + it('rejects (above_max) when estimated sats exceed MAX', async () => { + const res = await checkMarketOrderSatsLimits('USD', [6000], 0); + expect(res.status).to.equal('above_max'); + expect(res.limit).to.equal(5000); + }); + + it('on ranges checks the lower bound against MIN', async () => { + const res = await checkMarketOrderSatsLimits('USD', [50, 200], 0); + expect(res.status).to.equal('below_min'); + }); + + it('on ranges checks the upper bound against MAX', async () => { + const res = await checkMarketOrderSatsLimits('USD', [200, 6000], 0); + expect(res.status).to.equal('above_max'); + }); + + it('applies the price margin to the estimate', async () => { + // 200 fiat -> 200 sats, with +60% premium -> floor(80) = 80 < MIN(100) + const res = await checkMarketOrderSatsLimits('USD', [200], 60); + expect(res.status).to.equal('below_min'); + }); + + it('returns price_unavailable when the oracle reports an error', async () => { + axiosGet.reset(); + axiosGet.resolves({ data: { error: true } }); + const res = await checkMarketOrderSatsLimits('USD', [100], 0); + expect(res.status).to.equal('price_unavailable'); + }); + + it('returns price_unavailable when the oracle request throws', async () => { + axiosGet.reset(); + axiosGet.rejects(new Error('network down')); + const res = await checkMarketOrderSatsLimits('USD', [100], 0); + expect(res.status).to.equal('price_unavailable'); + }); + }); + + describe('validateSellOrder (market price sats estimate)', () => { + const loadWith = (checkStub: any) => + proxyquire('../../bot/validations', { + '../util': { checkMarketOrderSatsLimits: checkStub }, + }).validateSellOrder; + + beforeEach(() => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + }); + + it('rejects when the estimate is below the minimum', async () => { + const checkStub = sinon + .stub() + .resolves({ status: 'below_min', limit: 100 }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '50', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.equal(false); + expect(checkStub.calledOnce).to.equal(true); + }); + + it('rejects when the estimate exceeds the maximum', async () => { + const checkStub = sinon + .stub() + .resolves({ status: 'above_max', limit: 5000 }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '100000', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.equal(false); + }); + + it('accepts when the estimate is within limits', async () => { + const checkStub = sinon.stub().resolves({ status: 'ok' }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '100', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.be.an('object'); + }); + + it('accepts (pass-through) when the price oracle is unavailable', async () => { + const checkStub = sinon.stub().resolves({ status: 'price_unavailable' }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '100', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.be.an('object'); + expect(checkStub.calledOnce).to.equal(true); + }); + }); + + describe('validateBuyOrder (market price sats estimate)', () => { + const loadWith = (checkStub: any) => + proxyquire('../../bot/validations', { + '../util': { checkMarketOrderSatsLimits: checkStub }, + }).validateBuyOrder; + + beforeEach(() => { + sandbox.restore(); + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value({ + MIN_PAYMENT_AMT: 100, + MAX_PAYMENT_AMT: 5000, + NODE_ENV: 'production', + INVOICE_EXPIRATION_WINDOW: 3600000, + }); + }); + + it('rejects when the estimate is below the minimum', async () => { + const checkStub = sinon + .stub() + .resolves({ status: 'below_min', limit: 100 }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '50', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.equal(false); + expect(checkStub.calledOnce).to.equal(true); + }); + + it('accepts when the estimate is within limits', async () => { + const checkStub = sinon.stub().resolves({ status: 'ok' }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '100', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.be.an('object'); + }); + }); }); diff --git a/util/index.ts b/util/index.ts index dc6ab966..e86ea058 100644 --- a/util/index.ts +++ b/util/index.ts @@ -184,6 +184,49 @@ const getBtcFiatPrice = async (fiatCode: string, fiatAmount: number) => { } }; +type SatsLimitCheck = + | { status: 'ok' } + | { status: 'below_min'; limit: number } + | { status: 'above_max'; limit: number } + | { status: 'price_unavailable' }; + +// For market price orders (amount === 0) the final sats are only known when the +// order is taken. To avoid publishing orders that would settle below/above the +// configured limits, we estimate the sats at the current market price using the +// same formula applied at take time (see bot/commands.ts), and validate that +// estimate against MIN_PAYMENT_AMT / MAX_PAYMENT_AMT. +// For ranges [lo, hi]: the lower fiat yields the fewest sats (checked vs MIN) +// and the higher fiat yields the most sats (checked vs MAX). +const checkMarketOrderSatsLimits = async ( + fiatCode: string, + fiatAmount: number[], + priceMargin = 0, +): Promise => { + const min = Number(process.env.MIN_PAYMENT_AMT); + const max = Number(process.env.MAX_PAYMENT_AMT); + const marginPercent = priceMargin / 100; + const lo = fiatAmount[0]; + const hi = fiatAmount[fiatAmount.length - 1]; + + const estimate = async (fiat: number) => { + const base = await getBtcFiatPrice(fiatCode, fiat); + if (!base) return undefined; + return Math.floor(base - base * marginPercent); + }; + + const loSats = await estimate(lo); + if (loSats === undefined) return { status: 'price_unavailable' }; + if (Number.isFinite(min) && min > 0 && loSats < min) + return { status: 'below_min', limit: min }; + + const hiSats = lo === hi ? loSats : await estimate(hi); + if (hiSats === undefined) return { status: 'price_unavailable' }; + if (Number.isFinite(max) && max > 0 && hiSats > max) + return { status: 'above_max', limit: max }; + + return { status: 'ok' }; +}; + const getBtcExchangePrice = (fiatAmount: number, satsAmount: number) => { try { const satsPerBtc = 1e8; @@ -696,6 +739,7 @@ export { getCurrency, handleReputationItems, getBtcFiatPrice, + checkMarketOrderSatsLimits, getBtcExchangePrice, getCurrenciesWithPrice, getEmojiRate, From fb2df342fb96c67c70d5b963f8463d6307f4a2ec Mon Sep 17 00:00:00 2001 From: Tori Date: Tue, 16 Jun 2026 00:36:11 -0500 Subject: [PATCH 7/8] test: add buy-side above_max and price_unavailable market-estimate cases Mirrors the sell-side coverage so validateBuyOrder asserts all four checkMarketOrderSatsLimits statuses (below_min, above_max, ok, price_unavailable), per CodeRabbit review on #778. --- tests/bot/validation.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index 8b8073fd..aa7ada62 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -1521,6 +1521,16 @@ describe('Validations', () => { expect(checkStub.calledOnce).to.equal(true); }); + it('rejects when the estimate exceeds the maximum', async () => { + const checkStub = sinon + .stub() + .resolves({ status: 'above_max', limit: 5000 }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '100000', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.equal(false); + }); + it('accepts when the estimate is within limits', async () => { const checkStub = sinon.stub().resolves({ status: 'ok' }); const validate = loadWith(checkStub); @@ -1528,5 +1538,14 @@ describe('Validations', () => { const result = await validate(ctx); expect(result).to.be.an('object'); }); + + it('accepts (pass-through) when the price oracle is unavailable', async () => { + const checkStub = sinon.stub().resolves({ status: 'price_unavailable' }); + const validate = loadWith(checkStub); + ctx.state.command.args = ['0', '100', 'USD', 'zelle']; + const result = await validate(ctx); + expect(result).to.be.an('object'); + expect(checkStub.calledOnce).to.equal(true); + }); }); }); From 5d0c94d19bcd2215a2dba029729e51ff184ad5af Mon Sep 17 00:00:00 2001 From: Tori Date: Sun, 26 Jul 2026 23:03:20 -0500 Subject: [PATCH 8/8] fix: fail closed and revalidate market-order sats limits at take time Two gaps in the market-price MIN/MAX enforcement, from the PR #778 review: 1. Publication failed open: when the price oracle was unavailable the order was published unbounded. It now fails closed with user feedback, but only when bounds are configured (checkMarketOrderSatsLimits short-circuits to 'ok' when neither MIN nor MAX is set, so orders are never needlessly blocked and no price lookup is made). 2. The definitive sats are computed when the order is taken but were never revalidated, so price movement between publication and take could settle an order below MIN or above MAX. Both take paths (addInvoice and showHoldInvoice) now re-check via satsLimitViolation before mutating the order or creating the hold invoice. Tests: oracle-unavailable now asserts fail-closed; add satsLimitViolation unit tests (below_min, above_max, within-bounds, no-bounds, min-only). --- bot/commands.ts | 16 ++++++++++++ bot/messages.ts | 24 +++++++++++++++++ bot/validations.ts | 12 +++++++-- tests/bot/validation.spec.ts | 36 ++++++++++++++++++++------ tests/util/index.spec.ts | 50 ++++++++++++++++++++++++++++++++++++ util/index.ts | 31 +++++++++++++++++++--- 6 files changed, 155 insertions(+), 14 deletions(-) diff --git a/bot/commands.ts b/bot/commands.ts index 8d792240..9cb8f513 100644 --- a/bot/commands.ts +++ b/bot/commands.ts @@ -10,6 +10,7 @@ import { Order, User, Dispute } from '../models'; import * as messages from './messages'; import { getBtcFiatPrice, + satsLimitViolation, deleteOrderFromChannel, getUserI18nContext, getFee, @@ -167,6 +168,13 @@ const addInvoice = async ( const marginPercent = order.price_margin / 100; amount = amount - amount * marginPercent; amount = Math.floor(amount); + // The market price can drift out of MIN/MAX between publication and take, + // so re-check the definitive sats before mutating the order (PR #778). + const violation = satsLimitViolation(amount); + if (violation) { + await messages.satsLimitViolationMessage(ctx, violation); + return; + } order.fee = await getFee(amount, order.community_id); order.amount = amount; } @@ -488,6 +496,14 @@ const showHoldInvoice = async ( amount = amount - amount * marginPercent; amount = Math.floor(amount); + // The market price can drift out of MIN/MAX between publication and take, + // so re-check the definitive sats before creating the hold invoice (PR #778). + const violation = satsLimitViolation(amount); + if (violation) { + await messages.satsLimitViolationMessage(ctx, violation); + return; + } + order.fee = await getFee(amount, order.community_id); order.amount = amount; } diff --git a/bot/messages.ts b/bot/messages.ts index 0ff0bac5..7975bd97 100644 --- a/bot/messages.ts +++ b/bot/messages.ts @@ -977,6 +977,17 @@ const mustBeLessEqThan = async ( } }; +// Tells the taker their market order can no longer settle within MIN/MAX because +// the live price drifted between publication and take (see bot/commands.ts). +const satsLimitViolationMessage = async ( + ctx: MainContext, + violation: { status: 'below_min' | 'above_max'; limit: number }, +) => { + if (violation.status === 'below_min') + await mustBeGreatherEqThan(ctx, ctx.i18n.t('sats_amount'), violation.limit); + else await mustBeLessEqThan(ctx, ctx.i18n.t('sats_amount'), violation.limit); +}; + const bannedUserErrorMessage = async (ctx: MainContext, user: UserDocument) => { try { await ctx.telegram.sendMessage( @@ -1591,6 +1602,17 @@ const priceApiFailedMessage = async ( } }; +// Used when a market-price order can't be published because the price oracle is +// down and we therefore can't verify it respects MIN/MAX_PAYMENT_AMT. Replies in +// the current chat (the ctx author) rather than messaging a specific user. +const cantVerifySatsLimitsMessage = async (ctx: MainContext) => { + try { + await ctx.reply(ctx.i18n.t('problem_getting_price')); + } catch (error) { + logger.error(error); + } +}; + const updateUserSettingsMessage = async ( ctx: MainContext, field: string, @@ -2310,6 +2332,8 @@ export { rateUserMessage, listCurrenciesResponse, priceApiFailedMessage, + cantVerifySatsLimitsMessage, + satsLimitViolationMessage, showHoldInvoiceMessage, waitingForBuyerOrderMessage, invoiceUpdatedPaymentWillBeSendMessage, diff --git a/bot/validations.ts b/bot/validations.ts index 7ef091f9..b5dd8ba0 100644 --- a/bot/validations.ts +++ b/bot/validations.ts @@ -276,9 +276,13 @@ const validateSellOrder = async (ctx: MainContext) => { return false; } if (check.status === 'price_unavailable') { + // Fail closed: without a price we can't guarantee the order respects the + // configured MIN/MAX, so we don't publish it (see PR #778 review). logger.warning( - 'Market price order sats estimate skipped: price API unavailable', + 'Market price order rejected: price API unavailable, cannot verify sats limits', ); + await messages.cantVerifySatsLimitsMessage(ctx); + return false; } } @@ -410,9 +414,13 @@ const validateBuyOrder = async (ctx: MainContext) => { return false; } if (check.status === 'price_unavailable') { + // Fail closed: without a price we can't guarantee the order respects the + // configured MIN/MAX, so we don't publish it (see PR #778 review). logger.warning( - 'Market price order sats estimate skipped: price API unavailable', + 'Market price order rejected: price API unavailable, cannot verify sats limits', ); + await messages.cantVerifySatsLimitsMessage(ctx); + return false; } } diff --git a/tests/bot/validation.spec.ts b/tests/bot/validation.spec.ts index aa7ada62..ddd09844 100644 --- a/tests/bot/validation.spec.ts +++ b/tests/bot/validation.spec.ts @@ -189,7 +189,12 @@ describe('Validations', () => { INVOICE_EXPIRATION_WINDOW: 3600000, }); ctx.state.command.args = ['0', '100-200', 'USD', 'zelle']; - const result = await validateSellOrder(ctx); + const validate = proxyquire('../../bot/validations', { + '../util': { + checkMarketOrderSatsLimits: sinon.stub().resolves({ status: 'ok' }), + }, + }).validateSellOrder; + const result = await validate(ctx); expect(result).to.be.an('object'); if (result === false) throw new Error('object expected'); expect(result.amount).to.equal(0); @@ -231,7 +236,12 @@ describe('Validations', () => { it('should work with ranges', async () => { ctx.state.command.args = ['0', '100-200', 'USD', 'zelle', '5']; - const result = await validateSellOrder(ctx); + const validate = proxyquire('../../bot/validations', { + '../util': { + checkMarketOrderSatsLimits: sinon.stub().resolves({ status: 'ok' }), + }, + }).validateSellOrder; + const result = await validate(ctx); if (result === false) throw new Error('object expected'); expect(result.fiatAmount).to.deep.equal([100, 200]); }); @@ -322,7 +332,12 @@ describe('Validations', () => { INVOICE_EXPIRATION_WINDOW: 3600000, }); ctx.state.command.args = ['0', '100-200', 'USD', 'zelle']; - const result = await validateBuyOrder(ctx); + const validate = proxyquire('../../bot/validations', { + '../util': { + checkMarketOrderSatsLimits: sinon.stub().resolves({ status: 'ok' }), + }, + }).validateBuyOrder; + const result = await validate(ctx); expect(result).to.be.an('object'); if (result === false) throw new Error('object expected'); expect(result.amount).to.equal(0); @@ -364,7 +379,12 @@ describe('Validations', () => { it('should work with ranges', async () => { ctx.state.command.args = ['0', '100-200', 'USD', 'zelle', '5']; - const result = await validateBuyOrder(ctx); + const validate = proxyquire('../../bot/validations', { + '../util': { + checkMarketOrderSatsLimits: sinon.stub().resolves({ status: 'ok' }), + }, + }).validateBuyOrder; + const result = await validate(ctx); if (result === false) throw new Error('object expected'); expect(result.fiatAmount).to.deep.equal([100, 200]); }); @@ -1483,12 +1503,12 @@ describe('Validations', () => { expect(result).to.be.an('object'); }); - it('accepts (pass-through) when the price oracle is unavailable', async () => { + it('rejects (fail closed) when the price oracle is unavailable', async () => { const checkStub = sinon.stub().resolves({ status: 'price_unavailable' }); const validate = loadWith(checkStub); ctx.state.command.args = ['0', '100', 'USD', 'zelle']; const result = await validate(ctx); - expect(result).to.be.an('object'); + expect(result).to.equal(false); expect(checkStub.calledOnce).to.equal(true); }); }); @@ -1539,12 +1559,12 @@ describe('Validations', () => { expect(result).to.be.an('object'); }); - it('accepts (pass-through) when the price oracle is unavailable', async () => { + it('rejects (fail closed) when the price oracle is unavailable', async () => { const checkStub = sinon.stub().resolves({ status: 'price_unavailable' }); const validate = loadWith(checkStub); ctx.state.command.args = ['0', '100', 'USD', 'zelle']; const result = await validate(ctx); - expect(result).to.be.an('object'); + expect(result).to.equal(false); expect(checkStub.calledOnce).to.equal(true); }); }); diff --git a/tests/util/index.spec.ts b/tests/util/index.spec.ts index 52b321ea..10cec072 100644 --- a/tests/util/index.spec.ts +++ b/tests/util/index.spec.ts @@ -8,9 +8,11 @@ import { toKebabCase, getDetailedOrder, getUserI18nContext, + satsLimitViolation, } from '../../util/index'; const { expect } = require('chai'); +const sinon = require('sinon'); describe('Utility Functions', () => { describe('getCurrency', () => { @@ -222,4 +224,52 @@ describe('Utility Functions', () => { expect(ctx.locale()).to.equal('en'); }); }); + + // satsLimitViolation re-checks the definitive sats of a market-price order at + // take time, since the live price can drift out of MIN/MAX between publication + // and take (see PR #778 review). It reads MIN/MAX_PAYMENT_AMT from the env. + describe('satsLimitViolation', () => { + let sandbox: any; + + afterEach(() => { + if (sandbox) sandbox.restore(); + }); + + const withEnv = (env: Record) => { + sandbox = sinon.createSandbox(); + sandbox.stub(process, 'env').value(env); + }; + + it('returns below_min when sats fall under MIN_PAYMENT_AMT', () => { + withEnv({ MIN_PAYMENT_AMT: 100, MAX_PAYMENT_AMT: 5000 }); + expect(satsLimitViolation(50)).to.deep.equal({ + status: 'below_min', + limit: 100, + }); + }); + + it('returns above_max when sats exceed MAX_PAYMENT_AMT', () => { + withEnv({ MIN_PAYMENT_AMT: 100, MAX_PAYMENT_AMT: 5000 }); + expect(satsLimitViolation(6000)).to.deep.equal({ + status: 'above_max', + limit: 5000, + }); + }); + + it('returns null when sats are within the configured bounds', () => { + withEnv({ MIN_PAYMENT_AMT: 100, MAX_PAYMENT_AMT: 5000 }); + expect(satsLimitViolation(1000)).to.equal(null); + }); + + it('returns null when no bounds are configured', () => { + withEnv({}); + expect(satsLimitViolation(1)).to.equal(null); + }); + + it('enforces only MIN when MAX is not configured', () => { + withEnv({ MIN_PAYMENT_AMT: 100 }); + expect(satsLimitViolation(50)?.status).to.equal('below_min'); + expect(satsLimitViolation(1000000)).to.equal(null); + }); + }); }); diff --git a/util/index.ts b/util/index.ts index e86ea058..f4e055b2 100644 --- a/util/index.ts +++ b/util/index.ts @@ -204,6 +204,12 @@ const checkMarketOrderSatsLimits = async ( ): Promise => { const min = Number(process.env.MIN_PAYMENT_AMT); const max = Number(process.env.MAX_PAYMENT_AMT); + const hasMin = Number.isFinite(min) && min > 0; + const hasMax = Number.isFinite(max) && max > 0; + // Nothing to enforce: skip the price lookup so callers don't fail closed when + // there are no configured bounds a market order could ever violate. + if (!hasMin && !hasMax) return { status: 'ok' }; + const marginPercent = priceMargin / 100; const lo = fiatAmount[0]; const hi = fiatAmount[fiatAmount.length - 1]; @@ -216,17 +222,33 @@ const checkMarketOrderSatsLimits = async ( const loSats = await estimate(lo); if (loSats === undefined) return { status: 'price_unavailable' }; - if (Number.isFinite(min) && min > 0 && loSats < min) - return { status: 'below_min', limit: min }; + if (hasMin && loSats < min) return { status: 'below_min', limit: min }; const hiSats = lo === hi ? loSats : await estimate(hi); if (hiSats === undefined) return { status: 'price_unavailable' }; - if (Number.isFinite(max) && max > 0 && hiSats > max) - return { status: 'above_max', limit: max }; + if (hasMax && hiSats > max) return { status: 'above_max', limit: max }; return { status: 'ok' }; }; +// Re-checks a known sats amount against MIN/MAX_PAYMENT_AMT. Market-price orders +// are validated against an estimate at publication, but the definitive sats are +// only computed when the order is taken; the live price can drift out of range +// in between, so both take paths (see bot/commands.ts) re-check here before +// creating the hold invoice. Returns the violated bound, or null if within +// limits (or no bounds are configured). +const satsLimitViolation = ( + sats: number, +): { status: 'below_min' | 'above_max'; limit: number } | null => { + const min = Number(process.env.MIN_PAYMENT_AMT); + const max = Number(process.env.MAX_PAYMENT_AMT); + if (Number.isFinite(min) && min > 0 && sats < min) + return { status: 'below_min', limit: min }; + if (Number.isFinite(max) && max > 0 && sats > max) + return { status: 'above_max', limit: max }; + return null; +}; + const getBtcExchangePrice = (fiatAmount: number, satsAmount: number) => { try { const satsPerBtc = 1e8; @@ -740,6 +762,7 @@ export { handleReputationItems, getBtcFiatPrice, checkMarketOrderSatsLimits, + satsLimitViolation, getBtcExchangePrice, getCurrenciesWithPrice, getEmojiRate,