Skip to content

Commit 7e37d9a

Browse files
committed
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.
1 parent 1890158 commit 7e37d9a

5 files changed

Lines changed: 300 additions & 2 deletions

File tree

bot/modules/community/communityContext.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface CommunityWizardState {
3232
channels: IOrderChannel[];
3333
fee: number;
3434
sats: number;
35-
fiatAmount: number[];
35+
fiatAmount?: number[];
3636
priceMargin: number;
3737
solvers: IUsernameId[];
3838
disputeChannel: any;

bot/modules/orders/scenes.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Scenes, Markup } from 'telegraf';
22
import { logger } from '../../../logger';
3-
import { getCurrency } from '../../../util';
3+
import { getCurrency, checkMarketOrderSatsLimits } from '../../../util';
44
import { Community } from '../../../models';
55
import * as ordersActions from '../../ordersActions';
66
import {
@@ -67,6 +67,35 @@ export const createOrder = new Scenes.WizardScene(
6767
return createOrderSteps.priceMargin(ctx);
6868
if (undefined === method) return createOrderSteps.method(ctx);
6969

70+
// Market price orders (sats === 0) settle in sats at take time. Estimate
71+
// the sats at the current market price and enforce MIN/MAX on the
72+
// estimate before creating the order. Covers both the range path and the
73+
// "market price" button, since both reach this gate with sats === 0.
74+
if (sats === 0) {
75+
const check = await checkMarketOrderSatsLimits(
76+
currency,
77+
fiatAmount,
78+
priceMargin ?? 0,
79+
);
80+
if (check.status === 'below_min' || check.status === 'above_max') {
81+
ctx.wizard.state.error = ctx.i18n.t(
82+
check.status === 'below_min'
83+
? 'must_be_gt_or_eq'
84+
: 'must_be_lt_or_eq',
85+
{ fieldName: ctx.i18n.t('sats_amount'), qty: check.limit },
86+
);
87+
ctx.wizard.state.fiatAmount = undefined;
88+
await ctx.wizard.state.updateUI();
89+
return createOrderSteps.fiatAmount(ctx);
90+
}
91+
if (check.status === 'price_unavailable') {
92+
logger.warning(
93+
'Market price order sats estimate skipped: price API unavailable',
94+
);
95+
}
96+
}
97+
98+
// We remove all special characters from the payment method(s)
7099
const replaceRegex = /[&/\\#,+~%.'":*?<>{}]/g;
71100
const paymentMethod = selectedMethods?.length
72101
? selectedMethods.map(m => m.replace(replaceRegex, '')).join(', ')

bot/validations.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
isDisputeSolver,
1818
removeLightningPrefix,
1919
isOrderCreator,
20+
checkMarketOrderSatsLimits,
2021
} from '../util';
2122
import { existLightningAddress } from '../lnurl/lnurl-pay';
2223
import { logger } from '../logger';
@@ -250,6 +251,37 @@ const validateSellOrder = async (ctx: MainContext) => {
250251
return false;
251252
}
252253

254+
// Market price orders (amount === 0) settle in sats at take time. Estimate
255+
// the sats at the current market price and enforce MIN/MAX on the estimate.
256+
if (amount === 0) {
257+
const check = await checkMarketOrderSatsLimits(
258+
fiatCode.toUpperCase(),
259+
fiatAmount,
260+
Number(priceMargin) || 0,
261+
);
262+
if (check.status === 'below_min') {
263+
await messages.mustBeGreatherEqThan(
264+
ctx,
265+
ctx.i18n.t('sats_amount'),
266+
check.limit,
267+
);
268+
return false;
269+
}
270+
if (check.status === 'above_max') {
271+
await messages.mustBeLessEqThan(
272+
ctx,
273+
ctx.i18n.t('sats_amount'),
274+
check.limit,
275+
);
276+
return false;
277+
}
278+
if (check.status === 'price_unavailable') {
279+
logger.warning(
280+
'Market price order sats estimate skipped: price API unavailable',
281+
);
282+
}
283+
}
284+
253285
paymentMethod = paymentMethod.replace(/[&/\\#,+~%.'":*?<>{}]/g, '');
254286

255287
return {
@@ -353,6 +385,37 @@ const validateBuyOrder = async (ctx: MainContext) => {
353385
return false;
354386
}
355387

388+
// Market price orders (amount === 0) settle in sats at take time. Estimate
389+
// the sats at the current market price and enforce MIN/MAX on the estimate.
390+
if (amount === 0) {
391+
const check = await checkMarketOrderSatsLimits(
392+
fiatCode.toUpperCase(),
393+
fiatAmount,
394+
Number(priceMargin) || 0,
395+
);
396+
if (check.status === 'below_min') {
397+
await messages.mustBeGreatherEqThan(
398+
ctx,
399+
ctx.i18n.t('sats_amount'),
400+
check.limit,
401+
);
402+
return false;
403+
}
404+
if (check.status === 'above_max') {
405+
await messages.mustBeLessEqThan(
406+
ctx,
407+
ctx.i18n.t('sats_amount'),
408+
check.limit,
409+
);
410+
return false;
411+
}
412+
if (check.status === 'price_unavailable') {
413+
logger.warning(
414+
'Market price order sats estimate skipped: price API unavailable',
415+
);
416+
}
417+
}
418+
356419
paymentMethod = paymentMethod.replace(/[&/\\#,+~%.'":*?<>{}]/g, '');
357420

358421
return {

tests/bot/validation.spec.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,4 +1367,166 @@ describe('Validations', () => {
13671367
expect(result).to.equal(true);
13681368
});
13691369
});
1370+
1371+
describe('checkMarketOrderSatsLimits (market price estimate)', () => {
1372+
let axiosGet: any;
1373+
let checkMarketOrderSatsLimits: any;
1374+
1375+
beforeEach(() => {
1376+
sandbox.restore();
1377+
sandbox = sinon.createSandbox();
1378+
sandbox.stub(process, 'env').value({
1379+
MIN_PAYMENT_AMT: 100,
1380+
MAX_PAYMENT_AMT: 5000,
1381+
FIAT_RATE_EP: 'http://fake-oracle',
1382+
NODE_ENV: 'test',
1383+
});
1384+
axiosGet = sinon.stub();
1385+
// 1 BTC = 1e8 fiat => estimated sats == fiat amount
1386+
axiosGet.resolves({ data: { btc: 1e8 } });
1387+
checkMarketOrderSatsLimits = proxyquire('../../util', {
1388+
axios: { default: { get: axiosGet }, __esModule: true },
1389+
}).checkMarketOrderSatsLimits;
1390+
});
1391+
1392+
it('rejects (below_min) when estimated sats are under MIN', async () => {
1393+
const res = await checkMarketOrderSatsLimits('USD', [50], 0);
1394+
expect(res.status).to.equal('below_min');
1395+
expect(res.limit).to.equal(100);
1396+
});
1397+
1398+
it('accepts (ok) when estimated sats are within limits', async () => {
1399+
const res = await checkMarketOrderSatsLimits('USD', [100], 0);
1400+
expect(res.status).to.equal('ok');
1401+
});
1402+
1403+
it('rejects (above_max) when estimated sats exceed MAX', async () => {
1404+
const res = await checkMarketOrderSatsLimits('USD', [6000], 0);
1405+
expect(res.status).to.equal('above_max');
1406+
expect(res.limit).to.equal(5000);
1407+
});
1408+
1409+
it('on ranges checks the lower bound against MIN', async () => {
1410+
const res = await checkMarketOrderSatsLimits('USD', [50, 200], 0);
1411+
expect(res.status).to.equal('below_min');
1412+
});
1413+
1414+
it('on ranges checks the upper bound against MAX', async () => {
1415+
const res = await checkMarketOrderSatsLimits('USD', [200, 6000], 0);
1416+
expect(res.status).to.equal('above_max');
1417+
});
1418+
1419+
it('applies the price margin to the estimate', async () => {
1420+
// 200 fiat -> 200 sats, with +60% premium -> floor(80) = 80 < MIN(100)
1421+
const res = await checkMarketOrderSatsLimits('USD', [200], 60);
1422+
expect(res.status).to.equal('below_min');
1423+
});
1424+
1425+
it('returns price_unavailable when the oracle reports an error', async () => {
1426+
axiosGet.reset();
1427+
axiosGet.resolves({ data: { error: true } });
1428+
const res = await checkMarketOrderSatsLimits('USD', [100], 0);
1429+
expect(res.status).to.equal('price_unavailable');
1430+
});
1431+
1432+
it('returns price_unavailable when the oracle request throws', async () => {
1433+
axiosGet.reset();
1434+
axiosGet.rejects(new Error('network down'));
1435+
const res = await checkMarketOrderSatsLimits('USD', [100], 0);
1436+
expect(res.status).to.equal('price_unavailable');
1437+
});
1438+
});
1439+
1440+
describe('validateSellOrder (market price sats estimate)', () => {
1441+
const loadWith = (checkStub: any) =>
1442+
proxyquire('../../bot/validations', {
1443+
'../util': { checkMarketOrderSatsLimits: checkStub },
1444+
}).validateSellOrder;
1445+
1446+
beforeEach(() => {
1447+
sandbox.restore();
1448+
sandbox = sinon.createSandbox();
1449+
sandbox.stub(process, 'env').value({
1450+
MIN_PAYMENT_AMT: 100,
1451+
MAX_PAYMENT_AMT: 5000,
1452+
NODE_ENV: 'production',
1453+
INVOICE_EXPIRATION_WINDOW: 3600000,
1454+
});
1455+
});
1456+
1457+
it('rejects when the estimate is below the minimum', async () => {
1458+
const checkStub = sinon
1459+
.stub()
1460+
.resolves({ status: 'below_min', limit: 100 });
1461+
const validate = loadWith(checkStub);
1462+
ctx.state.command.args = ['0', '50', 'USD', 'zelle'];
1463+
const result = await validate(ctx);
1464+
expect(result).to.equal(false);
1465+
expect(checkStub.calledOnce).to.equal(true);
1466+
});
1467+
1468+
it('rejects when the estimate exceeds the maximum', async () => {
1469+
const checkStub = sinon
1470+
.stub()
1471+
.resolves({ status: 'above_max', limit: 5000 });
1472+
const validate = loadWith(checkStub);
1473+
ctx.state.command.args = ['0', '100000', 'USD', 'zelle'];
1474+
const result = await validate(ctx);
1475+
expect(result).to.equal(false);
1476+
});
1477+
1478+
it('accepts when the estimate is within limits', async () => {
1479+
const checkStub = sinon.stub().resolves({ status: 'ok' });
1480+
const validate = loadWith(checkStub);
1481+
ctx.state.command.args = ['0', '100', 'USD', 'zelle'];
1482+
const result = await validate(ctx);
1483+
expect(result).to.be.an('object');
1484+
});
1485+
1486+
it('accepts (pass-through) when the price oracle is unavailable', async () => {
1487+
const checkStub = sinon.stub().resolves({ status: 'price_unavailable' });
1488+
const validate = loadWith(checkStub);
1489+
ctx.state.command.args = ['0', '100', 'USD', 'zelle'];
1490+
const result = await validate(ctx);
1491+
expect(result).to.be.an('object');
1492+
expect(checkStub.calledOnce).to.equal(true);
1493+
});
1494+
});
1495+
1496+
describe('validateBuyOrder (market price sats estimate)', () => {
1497+
const loadWith = (checkStub: any) =>
1498+
proxyquire('../../bot/validations', {
1499+
'../util': { checkMarketOrderSatsLimits: checkStub },
1500+
}).validateBuyOrder;
1501+
1502+
beforeEach(() => {
1503+
sandbox.restore();
1504+
sandbox = sinon.createSandbox();
1505+
sandbox.stub(process, 'env').value({
1506+
MIN_PAYMENT_AMT: 100,
1507+
MAX_PAYMENT_AMT: 5000,
1508+
NODE_ENV: 'production',
1509+
INVOICE_EXPIRATION_WINDOW: 3600000,
1510+
});
1511+
});
1512+
1513+
it('rejects when the estimate is below the minimum', async () => {
1514+
const checkStub = sinon
1515+
.stub()
1516+
.resolves({ status: 'below_min', limit: 100 });
1517+
const validate = loadWith(checkStub);
1518+
ctx.state.command.args = ['0', '50', 'USD', 'zelle'];
1519+
const result = await validate(ctx);
1520+
expect(result).to.equal(false);
1521+
expect(checkStub.calledOnce).to.equal(true);
1522+
});
1523+
1524+
it('accepts when the estimate is within limits', async () => {
1525+
const checkStub = sinon.stub().resolves({ status: 'ok' });
1526+
const validate = loadWith(checkStub);
1527+
ctx.state.command.args = ['0', '100', 'USD', 'zelle'];
1528+
const result = await validate(ctx);
1529+
expect(result).to.be.an('object');
1530+
});
1531+
});
13701532
});

util/index.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,49 @@ const getBtcFiatPrice = async (fiatCode: string, fiatAmount: number) => {
168168
}
169169
};
170170

171+
type SatsLimitCheck =
172+
| { status: 'ok' }
173+
| { status: 'below_min'; limit: number }
174+
| { status: 'above_max'; limit: number }
175+
| { status: 'price_unavailable' };
176+
177+
// For market price orders (amount === 0) the final sats are only known when the
178+
// order is taken. To avoid publishing orders that would settle below/above the
179+
// configured limits, we estimate the sats at the current market price using the
180+
// same formula applied at take time (see bot/commands.ts), and validate that
181+
// estimate against MIN_PAYMENT_AMT / MAX_PAYMENT_AMT.
182+
// For ranges [lo, hi]: the lower fiat yields the fewest sats (checked vs MIN)
183+
// and the higher fiat yields the most sats (checked vs MAX).
184+
const checkMarketOrderSatsLimits = async (
185+
fiatCode: string,
186+
fiatAmount: number[],
187+
priceMargin = 0,
188+
): Promise<SatsLimitCheck> => {
189+
const min = Number(process.env.MIN_PAYMENT_AMT);
190+
const max = Number(process.env.MAX_PAYMENT_AMT);
191+
const marginPercent = priceMargin / 100;
192+
const lo = fiatAmount[0];
193+
const hi = fiatAmount[fiatAmount.length - 1];
194+
195+
const estimate = async (fiat: number) => {
196+
const base = await getBtcFiatPrice(fiatCode, fiat);
197+
if (!base) return undefined;
198+
return Math.floor(base - base * marginPercent);
199+
};
200+
201+
const loSats = await estimate(lo);
202+
if (loSats === undefined) return { status: 'price_unavailable' };
203+
if (Number.isFinite(min) && min > 0 && loSats < min)
204+
return { status: 'below_min', limit: min };
205+
206+
const hiSats = lo === hi ? loSats : await estimate(hi);
207+
if (hiSats === undefined) return { status: 'price_unavailable' };
208+
if (Number.isFinite(max) && max > 0 && hiSats > max)
209+
return { status: 'above_max', limit: max };
210+
211+
return { status: 'ok' };
212+
};
213+
171214
const getBtcExchangePrice = (fiatAmount: number, satsAmount: number) => {
172215
try {
173216
const satsPerBtc = 1e8;
@@ -672,6 +715,7 @@ export {
672715
getCurrency,
673716
handleReputationItems,
674717
getBtcFiatPrice,
718+
checkMarketOrderSatsLimits,
675719
getBtcExchangePrice,
676720
getCurrenciesWithPrice,
677721
getEmojiRate,

0 commit comments

Comments
 (0)