Require min 1 confirmation in payment tier config.

Disallow minConfirmations: 0 for BTC and XMR tiers at startup validation, update env examples and deploy docs, and drop tx-detected UI copy and legacy tests.
This commit is contained in:
2026-09-08 23:31:23 +02:00
parent b3e683cd71
commit deb5f8c2c3
9 changed files with 20 additions and 59 deletions
@@ -1,14 +1,14 @@
import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 },
{ upToTotalFiat: '25', minConfirmations: 1 },
{ upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 }
] as const;
describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0);
it('returns the first tier for small orders', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
});
it('returns the middle tier for medium orders', () => {
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
).toBe('2/10');
});
it('treats zero-confirmation tiers as confirmed', () => {
it('returns compact progress at zero confirmations', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 0,
requiredConfirmations: 0,
requiredConfirmations: 3,
format: 'compact'
})
).toBe('Confirmed');
).toBe('0/3');
});
});
@@ -1,9 +1,6 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({
confirmations,
requiredConfirmations,
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
}
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`;
return `${confirmations}/${requiredConfirmations}`;
};
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
statusVariant: 'confirmed'
});
});
it('treats tx-detected invoices as confirmed for payment status display', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.showRefresh).toBe(false);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
});
});
describe('overpayment', () => {
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
};
const validTiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => {
expect(validateTiers(validTiers)).toHaveLength(0);
});
it('accepts numeric-only tiers without tx-detected (0)', () => {
const tiers =
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers)).toHaveLength(0);
});
it('rejects empty string', () => {
expect(validateTiers('').length).toBeGreaterThan(0);
});
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
expect(validateTiers('[]').length).toBeGreaterThan(0);
});
it('rejects tx-detected (0) more than once', () => {
it('rejects minConfirmations: 0', () => {
const tiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]';
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects tx-detected (0) on catch-all tier', () => {
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects legacy tx-detected string', () => {
it('rejects non-numeric minConfirmations', () => {
const tiers =
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
'[{"upToTotalFiat":"25","minConfirmations":"foo"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): boolean => {
};
const isMinConfirmations = (value: unknown): boolean =>
typeof value === 'number' && Number.isInteger(value) && value >= 0;
typeof value === 'number' && Number.isInteger(value) && value >= 1;
const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
if (typeof value !== 'object' || value === null) {
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
}
const tiers = parsed;
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
if (txDetectedTierCount > 1) {
return false;
}
const lastTier = tiers[tiers.length - 1];
if (lastTier.upToTotalFiat !== undefined) {
return false;
}
if (lastTier.minConfirmations === 0) {
return false;
}
for (let index = 0; index < tiers.length - 1; index++) {
const tier = tiers[index];
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
}
defaultMessage(): string {
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be an integer >= 1, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
}
}