From deb5f8c2c388c180c8a55bb284f1937a5435d185 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 8 Sep 2026 23:31:23 +0200 Subject: [PATCH] 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. --- .env.example | 4 ++-- .../resolveMinConfirmations.spec.ts | 6 ++--- ...atInvoicePaymentConfirmationStatus.spec.ts | 6 ++--- .../formatInvoicePaymentConfirmationStatus.ts | 5 +--- .../invoice/toStorefrontInvoiceView.spec.ts | 13 ----------- .../decorators/isConfirmationTiers.spec.ts | 23 ++++--------------- .../decorators/isConfirmationTiers.ts | 14 ++--------- cms/src/views/CmsShopSettingsView.vue | 2 +- deploy/DEPLOYMENT_GUIDE.md | 6 ++--- 9 files changed, 20 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 972d3f9..496693b 100644 --- a/.env.example +++ b/.env.example @@ -90,7 +90,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate PAYMENT_METHODS_ENABLED=xmr,btc -MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]' +MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]' MONERO_VERSION=0.18.3.4 MONERO_NETWORK=stagenet MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081 @@ -104,7 +104,7 @@ MONERO_WALLET_NAME=shop MONERO_WALLET_PASSWORD=change-me MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment) -BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]' +BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":6}]' BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment) ELECTRUM_VERSION=4.8.1 diff --git a/backend/src/utils/confirmation/resolveMinConfirmations.spec.ts b/backend/src/utils/confirmation/resolveMinConfirmations.spec.ts index 3c7cc57..1e46fe6 100644 --- a/backend/src/utils/confirmation/resolveMinConfirmations.spec.ts +++ b/backend/src/utils/confirmation/resolveMinConfirmations.spec.ts @@ -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', () => { diff --git a/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts index ca70b66..b86ae3c 100644 --- a/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts +++ b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.spec.ts @@ -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'); }); }); diff --git a/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts index 197147d..e19919e 100644 --- a/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts +++ b/backend/src/utils/invoice/formatInvoicePaymentConfirmationStatus.ts @@ -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}`; }; diff --git a/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts b/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts index 0e4eee4..080a9c6 100644 --- a/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts +++ b/backend/src/utils/invoice/toStorefrontInvoiceView.spec.ts @@ -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', () => { diff --git a/backend/src/validation/decorators/isConfirmationTiers.spec.ts b/backend/src/validation/decorators/isConfirmationTiers.spec.ts index b755d0d..39d8192 100644 --- a/backend/src/validation/decorators/isConfirmationTiers.spec.ts +++ b/backend/src/validation/decorators/isConfirmationTiers.spec.ts @@ -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); }); diff --git a/backend/src/validation/decorators/isConfirmationTiers.ts b/backend/src/validation/decorators/isConfirmationTiers.ts index 7b1d02f..6ebb8cb 100644 --- a/backend/src/validation/decorators/isConfirmationTiers.ts +++ b/backend/src/validation/decorators/isConfirmationTiers.ts @@ -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'; } } diff --git a/cms/src/views/CmsShopSettingsView.vue b/cms/src/views/CmsShopSettingsView.vue index 2df5cbb..a1a0a57 100644 --- a/cms/src/views/CmsShopSettingsView.vue +++ b/cms/src/views/CmsShopSettingsView.vue @@ -468,7 +468,7 @@ const submitShippingNote = async (): Promise => { }; const formatConfirmationTier = (tier: ConfirmationTier, currency: string): string => { - const requirement = tier.minConfirmations === 0 ? '0 (tx-detected)' : `${tier.minConfirmations} confirmations`; + const requirement = `${tier.minConfirmations} confirmations`; if (tier.upToTotalFiat === undefined) { return `Above previous tiers → ${requirement}`; diff --git a/deploy/DEPLOYMENT_GUIDE.md b/deploy/DEPLOYMENT_GUIDE.md index 82859b1..60ef735 100644 --- a/deploy/DEPLOYMENT_GUIDE.md +++ b/deploy/DEPLOYMENT_GUIDE.md @@ -61,20 +61,20 @@ Edit `.env.prod`. Mandatory configuration: | `VITE_API_BASE_URL` | `/api` | | `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` | -Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept unconfirmed / mempool); that tier cannot be the catch-all. +Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. Every tier must use `minConfirmations` >= 1. Monero example (default in `.env.example`): ```json [ - { "upToTotalFiat": "30", "minConfirmations": 0 }, + { "upToTotalFiat": "30", "minConfirmations": 1 }, { "upToTotalFiat": "100", "minConfirmations": 3 }, { "upToTotalFiat": "300", "minConfirmations": 5 }, { "minConfirmations": 10 } ] ``` -Orders up to 30 → 0 confirmations; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings. +Orders up to 30 → 1 confirmation; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings. ## 5. Bootstrap TLS certificates