-
Notifications
You must be signed in to change notification settings - Fork 139
feat: enforce min/max sats amount on orders #778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dfe7aa6
f3f51f5
05ca3bc
a07bb88
070ecc9
190c703
fb2df34
5d0c94d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 && | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: this still exempts |
||
| 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') { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Blocking — bounds fail open on oracle errors. |
||
| // 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 { | ||
|
|
||
There was a problem hiding this comment.
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 === 0skips 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.