Skip to content
2 changes: 2 additions & 0 deletions .env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -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 (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
Expand Down
16 changes: 16 additions & 0 deletions bot/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Order, User, Dispute } from '../models';
import * as messages from './messages';
import {
getBtcFiatPrice,
satsLimitViolation,
deleteOrderFromChannel,
getUserI18nContext,
getFee,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
42 changes: 42 additions & 0 deletions bot/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2258,6 +2297,7 @@ export {
termsMessage,
privacyMessage,
mustBeGreatherEqThan,
mustBeLessEqThan,
bannedUserErrorMessage,
fiatSentMessages,
orderOnfiatSentStatusMessages,
Expand Down Expand Up @@ -2292,6 +2332,8 @@ export {
rateUserMessage,
listCurrenciesResponse,
priceApiFailedMessage,
cantVerifySatsLimitsMessage,
satsLimitViolationMessage,
showHoldInvoiceMessage,
waitingForBuyerOrderMessage,
invoiceUpdatedPaymentWillBeSendMessage,
Expand Down
2 changes: 1 addition & 1 deletion bot/modules/community/communityContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export interface CommunityWizardState {
channels: IOrderChannel[];
fee: number;
sats: number;
fiatAmount: number[];
fiatAmount?: number[];
priceMargin: number;
solvers: IUsernameId[];
disputeChannel: any;
Expand Down
63 changes: 57 additions & 6 deletions bot/modules/orders/scenes.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(', ')
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same blocker in the wizard flow: input === 0 skips both min and max checks. That allows market-price orders with tiny fiat values to pass even when the estimated sats amount would violate the configured limits.

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;
},
Expand Down
110 changes: 105 additions & 5 deletions bot/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
isDisputeSolver,
removeLightningPrefix,
isOrderCreator,
checkMarketOrderSatsLimits,
} from '../util';
import { existLightningAddress } from '../lnurl/lnurl-pay';
import { logger } from '../logger';
Expand Down Expand Up @@ -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 &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: this still exempts amount === 0, so market-price orders bypass the new bounds entirely. If the rule is meant to apply to all orders, validate the estimated sats amount instead of skipping the check for zero-amount orders.

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;
Expand All @@ -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;
}

Expand All @@ -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') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Blocking — bounds fail open on oracle errors. price_unavailable only logs a warning and this validator still returns a valid market order; the buy validator and wizard do the same. A network error, malformed response, unsupported price currency, or missing endpoint therefore recreates the previous bypass and permits tiny/oversized orders while limits are configured. The tests at tests/bot/validation.spec.ts:1486-1493 and 1542-1549 explicitly assert this pass-through. Fail closed with a user-visible error whenever an active bound cannot be evaluated.

// 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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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 {
Expand Down
Loading