Repository: joinmarket-webui/jam (ts)

Issue:
bug: zero relative maker fee crashes backend
See https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1740

Jam should not allow input of zero relative fee when starting a maker service.

Pull request description:
fix: relative fee greater than zero
Resolves #861.

Before this commit, attempting to create a relative offer with zero fees resulted in the backend crashing.
After this commit, a user must input a value greater than zero to start a maker.

<img src="https://github.com/user-attachments/assets/d1853735-b2f1-43eb-a00e-4341986602d5" width=350 />


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 16fe66f..ef4783a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,7 +16,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        node-version: ['v20.10.0']
+        node-version: ['v22.11.0']
 
     steps:
       - name: Checkout
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000..bb8c76c
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1,1 @@
+v22.11.0
diff --git a/package.json b/package.json
index 2cb4025..629b5aa 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,8 @@
   "repository": "git@github.com:joinmarket-webui/jam.git",
   "license": "MIT",
   "engines": {
-    "node": ">=16.11.0",
-    "npm": ">=8.0.0"
+    "node": ">=22.11.0",
+    "npm": ">=10.9.0"
   },
   "homepage": ".",
   "devDependencies": {
diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx
index 9c9b3dc..1c0dab1 100644
--- a/src/components/Earn.tsx
+++ b/src/components/Earn.tsx
@@ -6,7 +6,21 @@ import { TFunction } from 'i18next'
 import { useSettings } from '../context/SettingsContext'
 import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo, WalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo, Offer } from '../context/ServiceInfoContext'
-import { factorToPercentage, isAbsoluteOffer, isRelativeOffer, isValidNumber, percentageToFactor } from '../utils'
+import {
+  calcOfferMinsizeMax,
+  factorToPercentage,
+  isAbsoluteOffer,
+  isRelativeOffer,
+  isValidNumber,
+  percentageToFactor,
+} from '../utils'
+import {
+  OFFER_FEE_ABS_MIN,
+  OFFER_FEE_REL_MAX,
+  OFFER_FEE_REL_MIN,
+  OFFER_FEE_REL_STEP,
+  OFFER_MINSIZE_MIN,
+} from '../constants/jam'
 import * as Api from '../libs/JmWalletApi'
 import * as fb from './fb/utils'
 import Sprite from './Sprite'
@@ -22,7 +36,6 @@ import Accordion from './Accordion'
 import BitcoinAmountInput, { AmountValue, toAmountValue } from './BitcoinAmountInput'
 import { isValidAmount } from './Send/helpers'
 import styles from './Earn.module.css'
-import { JM_DUST_THRESHOLD } from '../constants/config'
 
 // In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
 // Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
@@ -194,10 +207,6 @@ function CurrentOffer({ offer, nickname }: CurrentOfferProps) {
   )
 }
 
-const feeRelMin = 0.0
-const feeRelMax = 0.1 // 10%
-const feeRelPercentageStep = 0.0001
-
 interface EarnFormProps {
   initialValues?: EarnFormValues
   submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
@@ -217,22 +226,9 @@ const EarnForm = ({
 }: EarnFormProps) => {
   const { t } = useTranslation()
 
-  const maxAvailableBalanceInJar = useMemo(() => {
-    return Math.max(
-      0,
-      Math.max(
-        ...Object.values(walletInfo?.balanceSummary.accountBalances || []).map(
-          (it) => it.calculatedAvailableBalanceInSats,
-        ),
-      ),
-    )
-  }, [walletInfo])
-
-  const offerMinsizeMin = JM_DUST_THRESHOLD
-
   const offerMinsizeMax = useMemo(() => {
-    return Math.max(0, maxAvailableBalanceInJar - JM_DUST_THRESHOLD)
-  }, [maxAvailableBalanceInJar])
+    return walletInfo === undefined ? 0 : calcOfferMinsizeMax(walletInfo.balanceSummary.accountBalances)
+  }, [walletInfo])
 
   const validate = (values: EarnFormValues) => {
     const errors = {} as FormikErrors<EarnFormValues>
@@ -245,16 +241,16 @@ const EarnForm = ({
     }
 
     if (isRelOffer) {
-      if (!isValidNumber(values.feeRel) || values.feeRel < feeRelMin || values.feeRel > feeRelMax) {
+      if (!isValidNumber(values.feeRel) || values.feeRel < OFFER_FEE_REL_MIN || values.feeRel > OFFER_FEE_REL_MAX) {
         errors.feeRel = t('earn.feedback_invalid_rel_fee', {
-          feeRelPercentageMin: `${factorToPercentage(feeRelMin)}%`,
-          feeRelPercentageMax: `${factorToPercentage(feeRelMax)}%`,
+          feeRelPercentageMin: `${factorToPercentage(OFFER_FEE_REL_MIN)}%`,
+          feeRelPercentageMax: `${factorToPercentage(OFFER_FEE_REL_MAX)}%`,
         })
       }
     }
 
     if (isAbsOffer) {
-      if (!isValidNumber(values.feeAbs?.value) || values.feeAbs!.value! < 0) {
+      if (!isValidNumber(values.feeAbs?.value) || values.feeAbs!.value! < OFFER_FEE_ABS_MIN) {
         errors.feeAbs = t('earn.feedback_invalid_abs_fee')
       }
     }
@@ -263,11 +259,11 @@ const EarnForm = ({
       errors.minsize = t('earn.feedback_invalid_min_amount')
     } else {
       const minsize = values.minsize?.value || 0
-      if (offerMinsizeMin > offerMinsizeMax) {
+      if (OFFER_MINSIZE_MIN > offerMinsizeMax) {
         errors.minsize = t('earn.feedback_invalid_min_amount_insufficient_funds')
-      } else if (minsize < offerMinsizeMin || minsize > offerMinsizeMax) {
+      } else if (minsize < OFFER_MINSIZE_MIN || minsize > offerMinsizeMax) {
         errors.minsize = t('earn.feedback_invalid_min_amount_range', {
-          minAmountMin: offerMinsizeMin.toLocaleString(),
+          minAmountMin: OFFER_MINSIZE_MIN.toLocaleString(),
           minAmountMax: offerMinsizeMax.toLocaleString(),
         })
       }
@@ -276,15 +272,7 @@ const EarnForm = ({
   }
 
   return (
-    <Formik
-      initialValues={initialValues}
-      validate={validate}
-      onSubmit={onSubmit}
-      validateOnMount={true}
-      initialTouched={{
-        minsize: true,
-      }}
-    >
+    <Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
       {(props) => {
         const { handleSubmit, setFieldValue, handleBlur, values, touched, errors, isSubmitting } = props
         const minsizeField = props.getFieldProps<AmountValue>('minsize')
@@ -347,8 +335,8 @@ const EarnForm = ({
                             value={typeof values.feeRel === 'number' ? factorToPercentage(values.feeRel) : ''}
                             isValid={touched.feeRel && !errors.feeRel}
                             isInvalid={touched.feeRel && !!errors.feeRel}
-                            min={0}
-                            step={feeRelPercentageStep}
+                            min={factorToPercentage(OFFER_FEE_REL_MIN)}
+                            step={factorToPercentage(OFFER_FEE_REL_STEP)}
                           />
                           <rb.Form.Control.Feedback type="invalid">{errors.feeRel}</rb.Form.Control.Feedback>
                         </rb.InputGroup>
diff --git a/src/components/ImportWallet.tsx b/src/components/ImportWallet.tsx
index 1d85400..51006d7 100644
--- a/src/components/ImportWallet.tsx
+++ b/src/components/ImportWallet.tsx
@@ -23,7 +23,7 @@ import {
   isValidNumber,
   walletDisplayNameToFileName,
 } from '../utils'
-import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/config'
+import { JM_GAPLIMIT_DEFAULT, JM_GAPLIMIT_CONFIGKEY } from '../constants/jm'
 
 type ImportWalletDetailsFormValues = {
   mnemonicPhrase: MnemonicPhrase
diff --git a/src/components/Orderbook.tsx b/src/components/Orderbook.tsx
index 8dade15..6aafa77 100644
--- a/src/components/Orderbook.tsx
+++ b/src/components/Orderbook.tsx
@@ -17,7 +17,7 @@ import { BTC, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '../ut
 import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
 import ToggleSwitch from './ToggleSwitch'
 import { pseudoRandomNumber } from './Send/helpers'
-import { JM_DUST_THRESHOLD } from '../constants/config'
+import { JM_DUST_THRESHOLD } from '../constants/jm'
 import * as fb from './fb/utils'
 import styles from './Orderbook.module.css'
 
diff --git a/src/components/Send/index.tsx b/src/components/Send/index.tsx
index d1b4535..3bff98c 100644
--- a/src/components/Send/index.tsx
+++ b/src/components/Send/index.tsx
@@ -18,7 +18,7 @@ import { useServiceInfo, useReloadServiceInfo } from '../../context/ServiceInfoC
 import { useLoadConfigValue } from '../../context/ServiceConfigContext'
 import { useWaitForUtxosToBeSpent } from '../../hooks/WaitForUtxosToBeSpent'
 import { routes } from '../../constants/routes'
-import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/config'
+import { JM_MINIMUM_MAKERS_DEFAULT } from '../../constants/jm'
 import { initialNumCollaborators } from './helpers'
 
 const INITIAL_DESTINATION = null
diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx
index d386047..89f7875 100644
--- a/src/components/settings/FeeConfigModal.tsx
+++ b/src/components/settings/FeeConfigModal.tsx
@@ -5,32 +5,27 @@ import { Formik, FormikErrors, FormikProps, Field } from 'formik'
 import classNames from 'classnames'
 import Sprite from '../Sprite'
 import { TxFeeInputField, validateTxFee } from './TxFeeInputField'
-import { FEE_CONFIG_KEYS, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
+import { FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
 import { useUpdateConfigValues } from '../../context/ServiceConfigContext'
 import { isDebugFeatureEnabled } from '../../constants/debugFeatures'
+import { FEE_CONFIG_KEYS, JM_MAX_SWEEP_FEE_CHANGE_DEFAULT } from '../../constants/jm'
+import {
+  CJ_FEE_ABS_MAX,
+  CJ_FEE_ABS_MIN,
+  CJ_FEE_REL_MAX,
+  CJ_FEE_REL_MIN,
+  MAX_SWEEP_FEE_CHANGE_MAX,
+  MAX_SWEEP_FEE_CHANGE_MIN,
+  TX_FEES_FACTOR_MAX,
+  TX_FEES_FACTOR_MIN,
+} from '../../constants/jam'
 import ToggleSwitch from '../ToggleSwitch'
 import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils'
 import BitcoinAmountInput, { AmountValue, toAmountValue } from '../BitcoinAmountInput'
-import { JM_MAX_SWEEP_FEE_CHANGE_DEFAULT } from '../../constants/config'
 import styles from './FeeConfigModal.module.css'
 
 const __dev_allowFeeValuesReset = isDebugFeatureEnabled('allowFeeValuesReset')
 
-const TX_FEES_FACTOR_MIN = 0 // 0%
-/**
- * For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`),
- * the maximum randomization factor must not be too high.
- * Settling on 50% as a reasonable compromise until this problem is addressed.
- * Once resolved, this can be set to 100% again.
- */
-const TX_FEES_FACTOR_MAX = 0.5 // 50%
-const CJ_FEE_ABS_MIN = 1
-const CJ_FEE_ABS_MAX = 1_000_000 // 0.01 BTC - no enforcement by JM - this should be a "sane" max value
-const CJ_FEE_REL_MIN = 0.000001 // 0.0001%
-const CJ_FEE_REL_MAX = 0.05 // 5% - no enforcement by JM - this should be a "sane" max value
-const MAX_SWEEP_FEE_CHANGE_MIN = 0.5 // 50%
-const MAX_SWEEP_FEE_CHANGE_MAX = 1 // 100%
-
 interface FeeConfigModalProps {
   show: boolean
   onHide: () => void
diff --git a/src/constants/jam.ts b/src/constants/jam.ts
new file mode 100644
index 0000000..4ccabda
--- /dev/null
+++ b/src/constants/jam.ts
@@ -0,0 +1,25 @@
+import { percentageToFactor } from '../utils'
+import { JM_DUST_THRESHOLD } from './jm'
+
+export const TX_FEES_FACTOR_MIN = 0 // 0%
+/**
+ * For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`),
+ * the maximum randomization factor must not be too high.
+ * Settling on 50% as a reasonable compromise until this problem is addressed.
+ * Once resolved, this can be set to 100% again.
+ */
+export const TX_FEES_FACTOR_MAX = percentageToFactor(50) // 50%
+export const CJ_FEE_ABS_MIN = 1
+export const CJ_FEE_ABS_MAX = 1_000_000 // 0.01 BTC - no enforcement by JM - this should be a "sane" max value
+export const CJ_FEE_REL_MIN = percentageToFactor(0.0001)
+export const CJ_FEE_REL_MAX = percentageToFactor(5) // no enforcement by JM - this should be a "sane" max value
+export const MAX_SWEEP_FEE_CHANGE_MIN = percentageToFactor(50)
+export const MAX_SWEEP_FEE_CHANGE_MAX = percentageToFactor(100)
+
+export const OFFER_FEE_REL_MIN = percentageToFactor(0.0001)
+export const OFFER_FEE_REL_MAX = percentageToFactor(10)
+export const OFFER_FEE_REL_STEP = percentageToFactor(0.0001)
+
+export const OFFER_FEE_ABS_MIN = 0
+
+export const OFFER_MINSIZE_MIN = JM_DUST_THRESHOLD
diff --git a/src/constants/config.ts b/src/constants/jm.ts
similarity index 75%
rename from src/constants/config.ts
rename to src/constants/jm.ts
index f80c07e..c7ab5e1 100644
--- a/src/constants/config.ts
+++ b/src/constants/jm.ts
@@ -26,3 +26,11 @@ export const JM_DUST_THRESHOLD = 27_300
 
 // See: https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.11/src/jmclient/configure.py#L321 (last check on 2024-07-09 of v0.9.11)
 export const JM_MAX_SWEEP_FEE_CHANGE_DEFAULT = 0.8
+
+export const FEE_CONFIG_KEYS: Record<string, ConfigKey> = {
+  tx_fees: { section: 'POLICY', field: 'tx_fees' },
+  tx_fees_factor: { section: 'POLICY', field: 'tx_fees_factor' },
+  max_cj_fee_abs: { section: 'POLICY', field: 'max_cj_fee_abs' },
+  max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' },
+  max_sweep_fee_change: { section: 'POLICY', field: 'max_sweep_fee_change' },
+}
diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx
index f6e3642..46d92cb 100644
--- a/src/context/ServiceInfoContext.tsx
+++ b/src/context/ServiceInfoContext.tsx
@@ -11,7 +11,7 @@ import {
 import { useCurrentWallet, useClearCurrentWallet } from './WalletContext'
 import { useWebsocket } from './WebsocketContext'
 import { clearSession } from '../session'
-import { CJ_STATE_TAKER_RUNNING, CJ_STATE_MAKER_RUNNING } from '../constants/config'
+import { CJ_STATE_TAKER_RUNNING, CJ_STATE_MAKER_RUNNING } from '../constants/jm'
 import { noop, setIntervalDebounced, toSemVer, UNKNOWN_VERSION } from '../utils'
 
 import * as Api from '../libs/JmWalletApi'
diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx
index ed322f8..55b0308 100644
--- a/src/context/WalletContext.tsx
+++ b/src/context/WalletContext.tsx
@@ -3,7 +3,7 @@ import { getSession, setSession } from '../session'
 import * as fb from '../components/fb/utils'
 import * as Api from '../libs/JmWalletApi'
 import { WalletBalanceSummary, toBalanceSummary } from './BalanceSummary'
-import { JM_API_AUTH_TOKEN_EXPIRY } from '../constants/config'
+import { JM_API_AUTH_TOKEN_EXPIRY } from '../constants/jm'
 import { isDevMode } from '../constants/debugFeatures'
 import { setIntervalDebounced, walletDisplayName } from '../utils'
 
diff --git a/src/hooks/CoinjoinRequirements.ts b/src/hooks/CoinjoinRequirements.ts
index c6bab25..8e537f2 100644
--- a/src/hooks/CoinjoinRequirements.ts
+++ b/src/hooks/CoinjoinRequirements.ts
@@ -1,6 +1,6 @@
 import * as fb from '../components/fb/utils'
 import { groupByJar, Utxos } from '../context/WalletContext'
-import { JM_TAKER_UTXO_AGE_DEFAULT } from '../constants/config'
+import { JM_TAKER_UTXO_AGE_DEFAULT } from '../constants/jm'
 
 export type CoinjoinRequirementOptions = {
   minNumberOfUtxos: number // min amount of utxos available
diff --git a/src/hooks/Fees.ts b/src/hooks/Fees.ts
index fac10db..e29cfd2 100644
--- a/src/hooks/Fees.ts
+++ b/src/hooks/Fees.ts
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, useMemo } from 'react'
 import { useRefreshConfigValues } from '../context/ServiceConfigContext'
 import { AmountSats } from '../libs/JmWalletApi'
 import { isValidNumber } from '../utils'
+import { FEE_CONFIG_KEYS } from '../constants/jm'
 
 export type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte'
 export type TxFeeValue = number
@@ -15,14 +16,6 @@ export const toTxFeeValueUnit = (val?: TxFeeValue): TxFeeValueUnit | undefined =
   return val <= 1_000 ? 'blocks' : 'sats/kilo-vbyte'
 }
 
-export const FEE_CONFIG_KEYS = {
-  tx_fees: { section: 'POLICY', field: 'tx_fees' },
-  tx_fees_factor: { section: 'POLICY', field: 'tx_fees_factor' },
-  max_cj_fee_abs: { section: 'POLICY', field: 'max_cj_fee_abs' },
-  max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' },
-  max_sweep_fee_change: { section: 'POLICY', field: 'max_sweep_fee_change' },
-}
-
 export interface FeeValues {
   tx_fees?: TxFee
   tx_fees_factor?: number
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index f3698e4..d174493 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -66,7 +66,7 @@
     "splashscreen_description_line1": "Your wallet, your coins.",
     "splashscreen_description_line2": "100% open-source & open-design.",
     "splashscreen_warning_title": "Warning",
-    "splashscreen_warning_text": "While JoinMarket is tried and tested, Jam is anything but. It is beta software, so please <2>read the documentation</2> and use it with caution. You can <1>help to improve the project on GitHub</1>.",
+    "splashscreen_warning_text": "While JoinMarket is tried and tested, Jam is not. It is beta software, so please <2>read the documentation</2> and use it with caution. You can <1>help to improve the project on GitHub</1>.",
     "screen_1_title": "Welcome to Jam for JoinMarket!",
     "screen_1_description": "Jam is an interface for JoinMarket, a privacy-focused software solution that aims to improve the confidentiality and privacy of your bitcoin transactions. Its main focus is to facilitate the creation of collaborative transactions through a peer-to-peer marketplace.",
     "screen_2_title": "Collaborative Transactions",
diff --git a/src/utils.ts b/src/utils.ts
index 9a2db65..526c6c4 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,4 +1,6 @@
-import { AmountSats, OfferType, WalletFileName } from './libs/JmWalletApi'
+import { JM_DUST_THRESHOLD } from './constants/jm'
+import type { AccountBalances } from './context/BalanceSummary'
+import type { AmountSats, OfferType, WalletFileName } from './libs/JmWalletApi'
 
 const BTC_FORMATTER = new Intl.NumberFormat('en-US', {
   minimumIntegerDigits: 1,
@@ -135,3 +137,15 @@ export const setIntervalDebounced = (
     )
   })()
 }
+
+const calcMaxAvailableBalanceInJar = (accountBalances: AccountBalances) => {
+  return Math.max(0, Math.max(...Object.values(accountBalances || []).map((it) => it.calculatedAvailableBalanceInSats)))
+}
+
+export const calcOfferMinsizeMax = (
+  accountBalances: AccountBalances,
+  minBufferAmount: AmountSats = JM_DUST_THRESHOLD,
+) => {
+  const maxAvailableBalanceInJar = calcMaxAvailableBalanceInJar(accountBalances)
+  return Math.max(0, maxAvailableBalanceInJar - minBufferAmount)
+}


Test patch:
diff --git a/src/utils.test.ts b/src/utils.test.ts
index 937199e..6662127 100644
--- a/src/utils.test.ts
+++ b/src/utils.test.ts
@@ -7,6 +7,7 @@ import {
   formatSats,
   formatBtc,
   formatBtcDisplayValue,
+  calcOfferMinsizeMax,
 } from './utils'
 
 describe('shortenStringMiddle', () => {
@@ -180,3 +181,41 @@ describe('formatBtcDisplayValue', () => {
     expect(formatBtcDisplayValue(123456789, { withSymbol: true })).toBe('₿ 1.23 456 789')
   })
 })
+
+describe('calcOfferMinsizeMax', () => {
+  it('should calc offer minsize based on wallet balance', () => {
+    expect(calcOfferMinsizeMax({})).toBe(0)
+    expect(
+      calcOfferMinsizeMax(
+        {
+          '0': {
+            accountIndex: 0,
+            calculatedTotalBalanceInSats: 21,
+            calculatedAvailableBalanceInSats: 21,
+            calculatedFrozenOrLockedBalanceInSats: 0,
+          },
+        },
+        0,
+      ),
+    ).toBe(21)
+    expect(
+      calcOfferMinsizeMax(
+        {
+          '0': {
+            accountIndex: 0,
+            calculatedTotalBalanceInSats: 42,
+            calculatedAvailableBalanceInSats: 41,
+            calculatedFrozenOrLockedBalanceInSats: 1,
+          },
+          '1': {
+            accountIndex: 1,
+            calculatedTotalBalanceInSats: 42_000,
+            calculatedAvailableBalanceInSats: 1,
+            calculatedFrozenOrLockedBalanceInSats: 41_999,
+          },
+        },
+        21,
+      ),
+    ).toBe(20)
+  })
+})