Skip to content

Commit 79a56b2

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 f29a398 commit 79a56b2

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 * as ordersActions from '../../ordersActions';
55
import {
66
publishBuyOrderMessage,
@@ -64,6 +64,35 @@ export const createOrder = new Scenes.WizardScene(
6464
if (undefined === priceMargin && sats === 0)
6565
return createOrderSteps.priceMargin(ctx);
6666
if (undefined === method) return createOrderSteps.method(ctx);
67+
68+
// Market price orders (sats === 0) settle in sats at take time. Estimate
69+
// the sats at the current market price and enforce MIN/MAX on the
70+
// estimate before creating the order. Covers both the range path and the
71+
// "market price" button, since both reach this gate with sats === 0.
72+
if (sats === 0) {
73+
const check = await checkMarketOrderSatsLimits(
74+
currency,
75+
fiatAmount,
76+
priceMargin ?? 0,
77+
);
78+
if (check.status === 'below_min' || check.status === 'above_max') {
79+
ctx.wizard.state.error = ctx.i18n.t(
80+
check.status === 'below_min'
81+
? 'must_be_gt_or_eq'
82+
: 'must_be_lt_or_eq',
83+
{ fieldName: ctx.i18n.t('sats_amount'), qty: check.limit },
84+
);
85+
ctx.wizard.state.fiatAmount = undefined;
86+
await ctx.wizard.state.updateUI();
87+
return createOrderSteps.fiatAmount(ctx);
88+
}
89+
if (check.status === 'price_unavailable') {
90+
logger.warning(
91+
'Market price order sats estimate skipped: price API unavailable',
92+
);
93+
}
94+
}
95+
6796
// We remove all special characters from the payment method
6897
const paymentMethod = method.replace(/[&/\\#,+~%.'":*?<>{}]/g, '');
6998

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';
@@ -247,6 +248,37 @@ const validateSellOrder = async (ctx: MainContext) => {
247248
return false;
248249
}
249250

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

252284
return {
@@ -350,6 +382,37 @@ const validateBuyOrder = async (ctx: MainContext) => {
350382
return false;
351383
}
352384

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

355418
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
@@ -167,6 +167,49 @@ const getBtcFiatPrice = async (fiatCode: string, fiatAmount: number) => {
167167
}
168168
};
169169

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

0 commit comments

Comments
 (0)