diff --git a/.env-sample b/.env-sample index 521cd577..df187526 100644 --- a/.env-sample +++ b/.env-sample @@ -56,6 +56,8 @@ ORDER_PUBLISHED_EXPIRATION_WINDOW=82800 # Minimum amount for a payment in satoshis MIN_PAYMENT_AMT=1 +# 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 MAX_PENDING_ORDERS=4 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 0aacf627..7975bd97 100644 --- a/bot/messages.ts +++ b/bot/messages.ts @@ -960,6 +960,34 @@ 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); + } +}; + +// 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( @@ -1574,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, @@ -2258,6 +2297,7 @@ export { termsMessage, privacyMessage, mustBeGreatherEqThan, + mustBeLessEqThan, bannedUserErrorMessage, fiatSentMessages, orderOnfiatSentStatusMessages, @@ -2292,6 +2332,8 @@ export { rateUserMessage, listCurrenciesResponse, priceApiFailedMessage, + cantVerifySatsLimitsMessage, + satsLimitViolationMessage, showHoldInvoiceMessage, waitingForBuyerOrderMessage, invoiceUpdatedPaymentWillBeSendMessage, 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 d66823d5..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(', ') @@ -392,7 +421,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,19 +465,41 @@ const createOrderHandlers = { await ctx.wizard.state.updateUI(); return true; } - const input = Number(ctx.message?.text); + if (!ctx.message?.text) { + return ctx.scene.leave(); + } + 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; } - ctx.wizard.state.sats = Math.floor(input); + 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) { + 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 = input; await ctx.wizard.state.updateUI(); return true; }, diff --git a/bot/validations.ts b/bot/validations.ts index 045eb3f4..b5dd8ba0 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'; @@ -206,16 +207,30 @@ 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, - '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, + ctx.i18n.t('sats_amount'), + maxPaymentAmt, + ); + return false; + } + if (fiatAmount.length === 2 && fiatAmount[1] <= fiatAmount[0]) { await messages.mustBeANumberOrRange(ctx); return false; @@ -227,7 +242,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; } @@ -236,6 +251,41 @@ 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') { + // 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 rejected: price API unavailable, cannot verify sats limits', + ); + await messages.cantVerifySatsLimitsMessage(ctx); + return false; + } + } + paymentMethod = paymentMethod.replace(/[&/\\#,+~%.'":*?<>{}]/g, ''); return { @@ -298,12 +348,27 @@ 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, + ctx.i18n.t('sats_amount'), + maxPaymentAmt, + ); + return false; + } + if (fiatAmount.length === 2 && fiatAmount[1] <= fiatAmount[0]) { await messages.mustBeANumberOrRange(ctx); return false; @@ -315,7 +380,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; } @@ -324,6 +389,41 @@ 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') { + // 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 rejected: price API unavailable, cannot verify sats limits', + ); + await messages.cantVerifySatsLimitsMessage(ctx); + return false; + } + } + paymentMethod = paymentMethod.replace(/[&/\\#,+~%.'":*?<>{}]/g, ''); return { diff --git a/locales/de.yaml b/locales/de.yaml index 1f8c9670..0d771d00 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 1baca341..857d4274 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 3c5685cb..ea62a082 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 38dd6cf2..d3f4e5f3 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 0d454777..95193951 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 67c24373..7b3c57b8 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 318d93d9..a48eed20 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 9db00489..83932878 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 e3ba1662..dcf10900 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 f30fec8e..3464c962 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..ddd09844 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'; @@ -143,6 +144,76 @@ 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 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); + }); + + 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); @@ -165,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]); }); @@ -217,6 +293,70 @@ 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 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); + }); + + 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); @@ -239,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]); }); @@ -1101,6 +1246,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 = { @@ -1142,4 +1387,185 @@ 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('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.equal(false); + 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('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('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.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 dc6ab966..f4e055b2 100644 --- a/util/index.ts +++ b/util/index.ts @@ -184,6 +184,71 @@ 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 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]; + + 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 (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 (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; @@ -696,6 +761,8 @@ export { getCurrency, handleReputationItems, getBtcFiatPrice, + checkMarketOrderSatsLimits, + satsLimitViolation, getBtcExchangePrice, getCurrenciesWithPrice, getEmojiRate,