Repository: joinmarket-webui/jam
Pull request #533 (2022-10-04 16:06:04)

regtest: randomize max fee settings
Companion PR of https://github.com/joinmarket-webui/jam-docker/pull/66 for the local regtest environment.


Patch:
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index 8642806..5d4adc3 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -45,6 +45,7 @@ export default function Jam() {
 
   const [alert, setAlert] = useState(null)
   const [isLoading, setIsLoading] = useState(true)
+  const [schedule, setSchedule] = useState(null)
   const [isWaitingSchedulerStart, setIsWaitingSchedulerStart] = useState(false)
   const [isWaitingSchedulerStop, setIsWaitingSchedulerStop] = useState(false)
   const collaborativeOperationRunning = useMemo(
@@ -103,44 +104,70 @@ export default function Jam() {
   }, [useInsecureTestingSettings, getNewAddressesForAccounts])
 
   useEffect(() => {
-    const abortCtrl = new AbortController()
-
     setAlert(null)
     setIsLoading(true)
 
+    const abortCtrl = new AbortController()
     const loadingServiceInfo = reloadServiceInfo({ signal: abortCtrl.signal }).catch((err) => {
+      if (abortCtrl.signal.aborted) return
       const message = err.message || t('send.error_loading_wallet_failed')
-      !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message })
+      setAlert({ variant: 'danger', message })
     })
 
     const loadingWalletInfo = reloadCurrentWalletInfo({ signal: abortCtrl.signal }).catch((err) => {
+      if (abortCtrl.signal.aborted) return
       const message = err.message || t('send.error_loading_wallet_failed')
-      !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message })
+      setAlert({ variant: 'danger', message })
     })
 
-    Promise.all([loadingServiceInfo, loadingWalletInfo]).finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
+    Promise.all([loadingServiceInfo, loadingWalletInfo]).finally(() => {
+      if (abortCtrl.signal.aborted) return
+      setIsLoading(false)
+    })
 
-    return () => abortCtrl.abort()
+    return () => {
+      abortCtrl.abort()
+    }
   }, [reloadServiceInfo, reloadCurrentWalletInfo, t])
 
   useEffect(() => {
     if (!serviceInfo) return
 
-    setIsWaitingSchedulerStart((current) => (current && serviceInfo.schedule ? false : current))
-    setIsWaitingSchedulerStop((current) => (current && !serviceInfo.schedule ? false : current))
+    const scheduleUpdate = serviceInfo.schedule
+    setSchedule(scheduleUpdate)
+
+    setIsWaitingSchedulerStart((current) => (current && scheduleUpdate ? false : current))
+    setIsWaitingSchedulerStop((current) => (current && !scheduleUpdate ? false : current))
 
-    if (serviceInfo.schedule && process.env.NODE_ENV === 'development') {
-      console.table(serviceInfo.schedule)
+    if (scheduleUpdate && process.env.NODE_ENV === 'development') {
+      console.table(scheduleUpdate)
     }
   }, [serviceInfo])
 
+  useEffect(() => {
+    // Due to polling, using `collaborativeOperationRunning` instead of
+    // `schedule` here, as a schedule object might still be present when
+    // the scheduler is actually not running anymore. Reload wallet data
+    // only when no collaborative operation is running anymore.
+    if (collaborativeOperationRunning) return
+
+    setIsLoading(true)
+    const abortCtrl = new AbortController()
+    reloadCurrentWalletInfo({ signal: abortCtrl.signal }).finally(() => {
+      if (abortCtrl.signal.aborted) return
+      setIsLoading(false)
+    })
+    return () => {
+      abortCtrl.abort()
+    }
+  }, [collaborativeOperationRunning, reloadCurrentWalletInfo])
+
   const startSchedule = async (values) => {
     if (isLoading || collaborativeOperationRunning) {
       return
     }
 
     setAlert(null)
-    setIsLoading(true)
     setIsWaitingSchedulerStart(true)
 
     const destinations = [values.dest1, values.dest2, values.dest3]
@@ -172,10 +199,6 @@ export default function Jam() {
         setAlert({ variant: 'danger', message: err.message })
         setIsWaitingSchedulerStart(false)
       })
-      .finally(() => {
-        if (abortCtrl.signal.aborted) return
-        setIsLoading(false)
-      })
   }
 
   const stopSchedule = async () => {
@@ -184,27 +207,17 @@ export default function Jam() {
     }
 
     setAlert(null)
-    setIsLoading(true)
     setIsWaitingSchedulerStop(true)
 
     const abortCtrl = new AbortController()
     return Api.getTakerStop({ signal: abortCtrl.signal, walletName: wallet.name, token: wallet.token })
       .then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_stopping_schedule_failed'))))
-      .then((_) =>
-        Promise.all([
-          reloadServiceInfo({ signal: abortCtrl.signal }),
-          reloadCurrentWalletInfo({ signal: abortCtrl.signal }),
-        ])
-      )
+      .then((_) => reloadServiceInfo({ signal: abortCtrl.signal }))
       .catch((err) => {
         if (abortCtrl.signal.aborted) return
         setAlert({ variant: 'danger', message: err.message })
         setIsWaitingSchedulerStop(false)
       })
-      .finally(() => {
-        if (abortCtrl.signal.aborted) return
-        setIsLoading(false)
-      })
   }
 
   return (
@@ -218,16 +231,15 @@ export default function Jam() {
         </rb.Placeholder>
       ) : (
         <>
-          {collaborativeOperationRunning && serviceInfo?.schedule && (
+          {collaborativeOperationRunning && (
             <div className="mb-4">
-              <ScheduleProgress schedule={serviceInfo.schedule} />
+              {schedule ? (
+                <ScheduleProgress schedule={schedule} />
+              ) : (
+                <rb.Alert variant="info">{t('send.text_coinjoin_already_running')}</rb.Alert>
+              )}
             </div>
           )}
-          {collaborativeOperationRunning && serviceInfo && !serviceInfo.schedule && (
-            <rb.Alert variant="info" className="mb-4">
-              {t('send.text_coinjoin_already_running')}
-            </rb.Alert>
-          )}
           <rb.Fade
             in={!collaborativeOperationRunning && !schedulerPreconditionSummary.isFulfilled}
             mountOnEnter={true}
@@ -263,7 +275,7 @@ export default function Jam() {
             </>
           )}
           {((!collaborativeOperationRunning && walletInfo && serviceInfo) ||
-            (collaborativeOperationRunning && serviceInfo?.schedule)) && (
+            (collaborativeOperationRunning && schedule)) && (
             <Formik
               initialValues={initialFormValues}
               validate={(values) => {
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index a047626..d65b8ba 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,4 +1,4 @@
-import React, { PropsWithChildren } from 'react'
+import { ReactNode, PropsWithChildren } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import styles from './Modal.module.css'
@@ -6,7 +6,7 @@ import Sprite from './Sprite'
 
 interface ConfirmModalProps {
   isShown: boolean
-  title: React.ReactNode | string
+  title: ReactNode | string
   onCancel: () => void
   onConfirm: () => void
 }
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index eba1d7e..a6b9911 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -13,16 +13,18 @@ import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } fr
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useLoadConfigValue } from '../context/ServiceConfigContext'
 import { useSettings } from '../context/SettingsContext'
+import { estimateMaxCollaboratorFee, toTxFeeValueUnit, useLoadFeeConfigValues } from '../hooks/Fees'
 import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
 
 import * as Api from '../libs/JmWalletApi'
-import { SATS, formatBtc, formatSats } from '../utils'
+import { SATS, formatBtc, formatSats, isValidNumber } from '../utils'
 import { routes } from '../constants/routes'
-import styles from './Send.module.css'
 import { ConfirmModal } from './Modal'
 import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
 import { jarInitial, jarName } from './jars/Jar'
 
+import styles from './Send.module.css'
+
 const IS_COINJOIN_DEFAULT_VAL = true
 // initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
 const MINIMUM_MAKERS_DEFAULT_VAL = 4
@@ -52,17 +54,17 @@ const isValidAddress = (candidate) => {
 
 const isValidAccount = (candidate) => {
   const parsed = parseInt(candidate, 10)
-  return !isNaN(parsed) && parsed >= 0
+  return isValidNumber(parsed) && parsed >= 0
 }
 
 const isValidAmount = (candidate, isSweep) => {
   const parsed = parseInt(candidate, 10)
-  return !isNaN(parsed) && (isSweep ? parsed === 0 : parsed > 0)
+  return isValidNumber(parsed) && (isSweep ? parsed === 0 : parsed > 0)
 }
 
 const isValidNumCollaborators = (candidate, minNumCollaborators) => {
   const parsed = parseInt(candidate, 10)
-  return !isNaN(parsed) && parsed >= minNumCollaborators && parsed <= 99
+  return isValidNumber(parsed) && parsed >= minNumCollaborators && parsed <= 99
 }
 
 const CollaboratorsSelector = ({ numCollaborators, setNumCollaborators, minNumCollaborators, disabled = false }) => {
@@ -214,6 +216,174 @@ function SweepAccordionToggle({ eventKey }) {
   )
 }
 
+function PaymentConfirmModal({
+  isShown,
+  title,
+  onCancel,
+  onConfirm,
+  data: { sourceJarId, destination, amount, isSweep, isCoinjoin, numCollaborators, feeConfigValues },
+}) {
+  const { t } = useTranslation()
+  const settings = useSettings()
+
+  const estimatedMaxCollaboratorFee = useMemo(() => {
+    if (!amount || !isCoinjoin || !numCollaborators || !feeConfigValues) return null
+    if (!isValidNumber(feeConfigValues.max_cj_fee_abs) || !isValidNumber(feeConfigValues.max_cj_fee_rel)) return null
+    return estimateMaxCollaboratorFee({
+      amount,
+      collaborators: numCollaborators,
+      maxFeeAbs: feeConfigValues.max_cj_fee_abs,
+      maxFeeRel: feeConfigValues.max_cj_fee_rel,
+    })
+  }, [amount, isCoinjoin, numCollaborators, feeConfigValues])
+
+  const miningFeeText = useMemo(() => {
+    if (!feeConfigValues) return null
+    if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
+
+    const unit = toTxFeeValueUnit(feeConfigValues.tx_fees)
+    if (!unit) {
+      return null
+    } else if (unit === 'blocks') {
+      return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees })
+    } else {
+      const feeTargetInSatsPerVByte = feeConfigValues.tx_fees / 1_000
+      if (feeConfigValues.tx_fees_factor === 0) {
+        return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
+          value: feeTargetInSatsPerVByte.toLocaleString(undefined, {
+            maximumFractionDigits: Math.log10(1_000),
+          }),
+        })
+      }
+
+      const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte * (1 - feeConfigValues.tx_fees_factor))
+      const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeConfigValues.tx_fees_factor)
+
+      return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
+        min: minFeeSatsPerVByte.toLocaleString(undefined, {
+          maximumFractionDigits: 1,
+        }),
+        max: maxFeeSatsPerVByte.toLocaleString(undefined, {
+          maximumFractionDigits: 1,
+        }),
+      })
+    }
+  }, [t, feeConfigValues])
+
+  return (
+    <ConfirmModal isShown={isShown} title={title} onCancel={onCancel} onConfirm={onConfirm}>
+      <rb.Container className="mt-2">
+        <rb.Row className="mt-2 mb-3">
+          <rb.Col xs={12} className="text-center">
+            {isCoinjoin ? (
+              <strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
+            ) : (
+              <strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
+            )}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start">
+            {t('send.confirm_send_modal.text_source_jar', { jarId: sourceJarId })}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_recipient')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
+            {destination}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_amount')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start">
+            {isSweep ? (
+              <>
+                <Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
+                  Sweep
+                  <Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
+                </Trans>
+                <rb.OverlayTrigger
+                  placement="right"
+                  overlay={
+                    <rb.Popover>
+                      <rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
+                    </rb.Popover>
+                  }
+                >
+                  <div className="d-inline-flex align-items-center">
+                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
+                  </div>
+                </rb.OverlayTrigger>
+              </>
+            ) : (
+              <Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
+            )}
+          </rb.Col>
+        </rb.Row>
+
+        {miningFeeText && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {miningFeeText}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {isCoinjoin && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {numCollaborators}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {estimatedMaxCollaboratorFee && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
+              <div>
+                &le;
+                <Balance
+                  valueString={`${estimatedMaxCollaboratorFee}`}
+                  convertToUnit={settings.unit}
+                  showBalance={true}
+                />
+                <rb.OverlayTrigger
+                  placement="right"
+                  overlay={
+                    <rb.Popover>
+                      <rb.Popover.Body>
+                        {t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
+                      </rb.Popover.Body>
+                    </rb.Popover>
+                  }
+                >
+                  <div className="d-inline-flex align-items-center">
+                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
+                  </div>
+                </rb.OverlayTrigger>
+              </div>
+            </rb.Col>
+          </rb.Row>
+        )}
+      </rb.Container>
+    </ConfirmModal>
+  )
+}
+
 export default function Send() {
   const { t } = useTranslation()
   const wallet = useCurrentWallet()
@@ -223,6 +393,7 @@ export default function Send() {
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
   const loadConfigValue = useLoadConfigValue()
+  const loadFeeConfigValues = useLoadFeeConfigValues()
   const settings = useSettings()
   const location = useLocation()
 
@@ -238,6 +409,8 @@ export default function Send() {
   const [destinationJar, setDestinationJar] = useState(null)
   const [destinationIsReusedAddress, setDestinationIsReusedAddress] = useState(false)
 
+  const [feeConfigValues, setFeeConfigValues] = useState(null)
+
   const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
   const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)
 
@@ -409,12 +582,24 @@ export default function Send() {
         !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message })
       })
 
-    Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig]).finally(
+    const loadFeeValues = loadFeeConfigValues(abortCtrl.signal)
+      .then((data) => {
+        if (abortCtrl.signal.aborted) return
+        setFeeConfigValues(data)
+      })
+      .catch((e) => {
+        if (abortCtrl.signal.aborted) return
+        // As fee config is not essential, don't raise an error on purpose.
+        // Fee settings cannot be displayed, but making a payment is still possible.
+        setFeeConfigValues(null)
+      })
+
+    Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig, loadFeeValues]).finally(
       () => !abortCtrl.signal.aborted && setIsInitializing(false)
     )
 
     return () => abortCtrl.abort()
-  }, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, t])
+  }, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, loadFeeConfigValues, t])
 
   useEffect(() => {
     if (destination !== null && walletInfo?.addressSummary[destination]) {
@@ -582,7 +767,7 @@ export default function Send() {
   }
 
   const amountFieldValue = () => {
-    if (amount === null || Number.isNaN(amount)) return ''
+    if (amount === null || !isValidNumber(amount)) return ''
 
     if (isSweep) {
       if (!accountBalanceOrNull) return ''
@@ -950,89 +1135,23 @@ export default function Send() {
           {t('send.confirm_abort_modal.text_body')}
         </ConfirmModal>
 
-        <ConfirmModal
+        <PaymentConfirmModal
           isShown={showConfirmSendModal}
           title={t('send.confirm_send_modal.title')}
           onCancel={() => setShowConfirmSendModal(false)}
           onConfirm={() => {
             submitButtonRef.current?.click()
           }}
-        >
-          <rb.Container className="mt-2">
-            <rb.Row className="mt-2 mb-3">
-              <rb.Col xs={12} className="text-center">
-                {isCoinjoin ? (
-                  <strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
-                ) : (
-                  <strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
-                )}
-              </rb.Col>
-            </rb.Row>
-            <rb.Row>
-              <rb.Col xs={3} className="text-end">
-                <strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
-              </rb.Col>
-              <rb.Col xs={9} className="text-start">
-                {t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(account) })}
-              </rb.Col>
-            </rb.Row>
-            <rb.Row>
-              <rb.Col xs={3} className="text-end">
-                <strong>{t('send.confirm_send_modal.label_recipient')}</strong>
-              </rb.Col>
-              <rb.Col xs={9} className="text-start text-break slashed-zeroes">
-                {destination}
-              </rb.Col>
-            </rb.Row>
-            <rb.Row>
-              <rb.Col xs={3} className="text-end">
-                <strong>{t('send.confirm_send_modal.label_amount')}</strong>
-              </rb.Col>
-              <rb.Col xs={9} className="text-start">
-                {isSweep ? (
-                  <div className="d-flex justify-content-start align-items-center">
-                    <Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
-                      Sweep
-                      <Balance
-                        valueString={amountFieldValue().toString()}
-                        convertToUnit={settings.unit}
-                        showBalance={true}
-                      />
-                    </Trans>
-                    <rb.OverlayTrigger
-                      placement="right"
-                      overlay={
-                        <rb.Popover>
-                          <rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
-                        </rb.Popover>
-                      }
-                    >
-                      <div className="d-inline-flex align-items-center">
-                        <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
-                      </div>
-                    </rb.OverlayTrigger>
-                  </div>
-                ) : (
-                  <Balance
-                    valueString={amountFieldValue().toString()}
-                    convertToUnit={settings.unit}
-                    showBalance={true}
-                  />
-                )}
-              </rb.Col>
-            </rb.Row>
-            {isCoinjoin && (
-              <rb.Row>
-                <rb.Col xs={3} className="text-end">
-                  <strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
-                </rb.Col>
-                <rb.Col xs={9} className="text-start">
-                  {numCollaborators}
-                </rb.Col>
-              </rb.Row>
-            )}
-          </rb.Container>
-        </ConfirmModal>
+          data={{
+            sourceJarId: jarInitial(account),
+            destination,
+            amount: amountFieldValue().toString(),
+            isSweep,
+            isCoinjoin,
+            numCollaborators,
+            feeConfigValues,
+          }}
+        />
       </div>
     </>
   )
diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx
index b2cc027..f9783ab 100644
--- a/src/components/settings/FeeConfigModal.tsx
+++ b/src/components/settings/FeeConfigModal.tsx
@@ -3,11 +3,12 @@ import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Formik, FormikErrors } from 'formik'
 import classNames from 'classnames'
-import { useRefreshConfigValues, useUpdateConfigValues } from '../../context/ServiceConfigContext'
-import { factorToPercentage, percentageToFactor } from '../../utils'
+import { FEE_CONFIG_KEYS, TxFeeValueUnit, toTxFeeValueUnit, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
+import { useUpdateConfigValues } from '../../context/ServiceConfigContext'
+import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils'
 import Sprite from '../Sprite'
-import styles from './FeeConfigModal.module.css'
 import SegmentedTabs from '../SegmentedTabs'
+import styles from './FeeConfigModal.module.css'
 
 type SatsPerKiloVByte = number
 
@@ -17,7 +18,6 @@ const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte
 // 350 sats/vbyte - no enforcement by JM - this should be a "sane" max value (taken default value of "absurd_fee_per_kb")
 const TX_FEES_SATSPERKILOVBYTE_MAX: SatsPerKiloVByte = 350_000
 const TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN = 1_001 // actual min of `tx_fees` if unit is sats/kilo-vbyte
-const TX_FEES_FACTOR_DEFAULT_VAL = 0.2 // 20%
 const TX_FEES_FACTOR_MIN = 0 // 0%
 const TX_FEES_FACTOR_MAX = 1 // 100%
 const CJ_FEE_ABS_MIN = 1
@@ -25,29 +25,11 @@ const CJ_FEE_ABS_MAX = 1_000_000 // 0.01 BTC - no enforcement by JM - this shoul
 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 isValidNumber = (val: number | undefined) => typeof val === 'number' && !isNaN(val)
-
-const FEE_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' },
-}
-
 interface FeeConfigModalProps {
   show: boolean
   onHide: () => void
 }
 
-type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte'
-
-interface FeeValues {
-  tx_fees?: number
-  tx_fees_factor?: number
-  max_cj_fee_abs?: number
-  max_cj_fee_rel?: number
-}
-
 interface FeeConfigFormProps {
   initialValues: FeeValues
   validate: (values: FeeValues, txFeesUnit: TxFeeValueUnit) => FormikErrors<FeeValues>
@@ -58,9 +40,7 @@ const FeeConfigForm = forwardRef(
   ({ onSubmit, validate, initialValues }: FeeConfigFormProps, ref: React.Ref<HTMLFormElement>) => {
     const { t, i18n } = useTranslation()
 
-    const [txFeesUnit, setTxFeesUnit] = useState<TxFeeValueUnit>(
-      initialValues.tx_fees && initialValues.tx_fees > 1_000 ? 'sats/kilo-vbyte' : 'blocks'
-    )
+    const [txFeesUnit, setTxFeesUnit] = useState<TxFeeValueUnit>(toTxFeeValueUnit(initialValues.tx_fees) || 'blocks')
 
     return (
       <Formik
@@ -312,8 +292,8 @@ const FeeConfigForm = forwardRef(
 
 export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
   const { t } = useTranslation()
-  const refreshConfigValues = useRefreshConfigValues()
   const updateConfigValues = useUpdateConfigValues()
+  const loadFeeConfigValues = useLoadFeeConfigValues()
   const [isLoading, setIsLoading] = useState(true)
   const [isSubmitting, setIsSubmitting] = useState(false)
   const [loadError, setLoadError] = useState(false)
@@ -322,47 +302,31 @@ export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
   const formRef = useRef<HTMLFormElement>(null)
 
   useEffect(() => {
-    const loadFeeValues = async (signal: AbortSignal) => {
-      setLoadError(false)
+    setLoadError(false)
+
+    const abortCtrl = new AbortController()
+    if (show) {
       setIsLoading(true)
 
-      try {
-        const serviceConfig = await refreshConfigValues({
-          signal,
-          keys: Object.values(FEE_KEYS),
+      loadFeeConfigValues(abortCtrl.signal)
+        .then((val) => {
+          if (abortCtrl.signal.aborted) return
+          setIsLoading(false)
+          setFeeConfigValues(val)
         })
-
-        setIsLoading(false)
-
-        const policy = serviceConfig['POLICY'] || {}
-
-        const feeValues: FeeValues = {
-          tx_fees: parseInt(policy.tx_fees || '', 10) || undefined,
-          tx_fees_factor: parseFloat(policy.tx_fees_factor || `${TX_FEES_FACTOR_DEFAULT_VAL}`),
-          max_cj_fee_abs: parseInt(policy.max_cj_fee_abs || '', 10) || undefined,
-          max_cj_fee_rel: parseFloat(policy.max_cj_fee_rel || '') || undefined,
-        }
-        setFeeConfigValues(feeValues)
-      } catch (e) {
-        if (!signal.aborted) {
+        .catch((e) => {
+          if (abortCtrl.signal.aborted) return
           setIsLoading(false)
           setLoadError(true)
-        }
-      }
-    }
-
-    const abortCtrl = new AbortController()
-    if (show) {
-      loadFeeValues(abortCtrl.signal)
+        })
     } else {
-      setLoadError(false)
       setSaveErrorMessage(undefined)
     }
 
     return () => {
       abortCtrl.abort()
     }
-  }, [show, refreshConfigValues])
+  }, [show, loadFeeConfigValues])
 
   const submit = async (feeValues: FeeValues, txFeesUnit: TxFeeValueUnit) => {
     const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined)
@@ -380,19 +344,19 @@ export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
 
     const updates = [
       {
-        key: FEE_KEYS.tx_fees,
+        key: FEE_CONFIG_KEYS.tx_fees,
         value: String(adjustedTxFees),
       },
       {
-        key: FEE_KEYS.tx_fees_factor,
+        key: FEE_CONFIG_KEYS.tx_fees_factor,
         value: String(feeValues.tx_fees_factor),
       },
       {
-        key: FEE_KEYS.max_cj_fee_abs,
+        key: FEE_CONFIG_KEYS.max_cj_fee_abs,
         value: String(feeValues.max_cj_fee_abs),
       },
       {
-        key: FEE_KEYS.max_cj_fee_rel,
+        key: FEE_CONFIG_KEYS.max_cj_fee_rel,
         value: String(feeValues.max_cj_fee_rel),
       },
     ]
diff --git a/src/hooks/Fees.ts b/src/hooks/Fees.ts
new file mode 100644
index 0000000..e772b0c
--- /dev/null
+++ b/src/hooks/Fees.ts
@@ -0,0 +1,71 @@
+import { useCallback } from 'react'
+import { useRefreshConfigValues } from '../context/ServiceConfigContext'
+import { AmountSats } from '../libs/JmWalletApi'
+import { isValidNumber } from '../utils'
+
+export type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte'
+
+export const toTxFeeValueUnit = (val?: number): TxFeeValueUnit | undefined => {
+  if (val === undefined || !Number.isInteger(val) || val < 1) return 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' },
+}
+
+export interface FeeValues {
+  tx_fees?: number
+  tx_fees_factor?: number
+  max_cj_fee_abs?: number
+  max_cj_fee_rel?: number
+}
+
+export const useLoadFeeConfigValues = () => {
+  const refreshConfigValues = useRefreshConfigValues()
+
+  return useCallback(
+    async (signal?: AbortSignal) => {
+      const serviceConfig = await refreshConfigValues({
+        signal,
+        keys: Object.values(FEE_CONFIG_KEYS),
+      })
+
+      const policy = serviceConfig['POLICY'] || {}
+
+      const parsedTxFees = parseInt(policy.tx_fees || '', 10)
+      const parsedTxFeesFactor = parseFloat(policy.tx_fees_factor || '')
+      const parsedMaxFeeAbs = parseInt(policy.max_cj_fee_abs || '', 10)
+      const parsedMaxFeeRel = parseFloat(policy.max_cj_fee_rel || '')
+
+      const feeValues: FeeValues = {
+        tx_fees: isValidNumber(parsedTxFees) ? parsedTxFees : undefined,
+        tx_fees_factor: isValidNumber(parsedTxFeesFactor) ? parsedTxFeesFactor : undefined,
+        max_cj_fee_abs: isValidNumber(parsedMaxFeeAbs) ? parsedMaxFeeAbs : undefined,
+        max_cj_fee_rel: isValidNumber(parsedMaxFeeRel) ? parsedMaxFeeRel : undefined,
+      }
+      return feeValues
+    },
+    [refreshConfigValues]
+  )
+}
+
+interface EstimatMaxCollaboratorFeeProps {
+  amount: AmountSats
+  collaborators: number
+  maxFeeAbs: AmountSats
+  maxFeeRel: number // e.g. 0.001 for 0.1%
+}
+
+export const estimateMaxCollaboratorFee = ({
+  amount,
+  collaborators,
+  maxFeeAbs,
+  maxFeeRel,
+}: EstimatMaxCollaboratorFeeProps) => {
+  const maxFeePerCollaborator = Math.max(Math.ceil(amount * maxFeeRel), maxFeeAbs)
+  return collaborators > 0 ? Math.min(maxFeePerCollaborator * collaborators, amount) : 0
+}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 87e2786..09ca897 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -248,7 +248,14 @@
       "text_source_jar": "Jar {{ jarId }}",
       "label_num_collaborators": "Collaborators",
       "text_collaborative_tx_enabled": "Payment with privacy improvement",
-      "text_collaborative_tx_disabled": "Payment without privacy improvement"
+      "text_collaborative_tx_disabled": "Payment without privacy improvement",
+      "label_estimated_max_collaborator_fee": "Collaborator fee",
+      "text_estimated_max_collaborator_fee_info_popover": "This value represents a maximum upper limit. The actual fee is likely to be much lower.",
+      "label_miner_fee": "Mining fee",
+      "text_miner_fee_in_satspervbyte_exact": "{{ value }} sats/vByte",
+      "text_miner_fee_in_satspervbyte_randomized": "{{ min }} - {{ max }} sats/vByte",
+      "text_miner_fee_in_targeted_blocks_one": "High priority estimate (next block)",
+      "text_miner_fee_in_targeted_blocks_other": "Using estimate for inclusion in next {{ count }} blocks"
     },
     "confirm_abort_modal": {
       "title": "Abort payment",
diff --git a/src/utils.ts b/src/utils.ts
index 62fa6d6..e952394 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -60,3 +60,5 @@ export const factorToPercentage = (val: number, precision = 6) => {
   // but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027
   return Number((val * 100).toFixed(precision))
 }
+
+export const isValidNumber = (val: number | undefined) => typeof val === 'number' && !isNaN(val)


Test patch:
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
index f2fcf9d..b091a1f 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
@@ -166,13 +166,13 @@ max_sweep_fee_change = 0.8
 # market maker for a transaction. Both the limits given in
 # max_cj_fee_abs and max_cj_fee_rel must be exceeded in order
 # to not consider a certain offer.
-max_cj_fee_abs = 300000 # 0.003 btc
+max_cj_fee_abs = 10000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200 million satoshi) and
 # max_cj_fee_rel = 0.001 (0.1%), the maximum fee allowed would
 # be 0.002 btc (200 thousand satoshi)
-max_cj_fee_rel = 0.0003 # 0.03%
+max_cj_fee_rel = 0.0003
 
 # The range of confirmations passed to the `listunspent` bitcoind RPC call
 # 1st value is the inclusive minimum, defaults to one confirmation
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
index d08aef1..ca4183c 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
@@ -178,7 +178,7 @@ max_sweep_fee_change = 0.8
 # to not consider a certain offer.
 
 # taken from jam production image
-max_cj_fee_abs = 300000
+max_cj_fee_abs = 10000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200 million satoshi) and
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh b/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh
index 7734fb2..b3e0a4b 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh
@@ -53,6 +53,12 @@ done < <(env -0)
 # ensure a wallet name is present
 jmenv['rpc_wallet_file']=${jmenv['rpc_wallet_file']:-'jm_webui_default'}
 
+# Make sure `max_cj_fee_abs` and `max_cj_fee_rel` are set
+# `max_cj_fee_abs` between 5000 - 10000 sats if not provided
+jmenv['max_cj_fee_abs']=${jmenv['max_cj_fee_abs']:-"$(shuf -i 5000-10000 -n1)"}
+# `max_cj_fee_rel` between 0.01 - 0.03% if not provided
+jmenv['max_cj_fee_rel']=${jmenv['max_cj_fee_rel']:-"0.000$((RANDOM%3+1))"}
+
 # adapt 'blockchain_source' if missing and we're in regtest mode
 if [ "${jmenv['network']}" = "regtest" ] && [ "${jmenv['blockchain_source']}" = "" ]; then
     jmenv['blockchain_source']='regtest'
diff --git a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
index d08aef1..ca4183c 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
@@ -178,7 +178,7 @@ max_sweep_fee_change = 0.8
 # to not consider a certain offer.
 
 # taken from jam production image
-max_cj_fee_abs = 300000
+max_cj_fee_abs = 10000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200 million satoshi) and
diff --git a/src/hooks/Fees.test.ts b/src/hooks/Fees.test.ts
new file mode 100644
index 0000000..8ebafd9
--- /dev/null
+++ b/src/hooks/Fees.test.ts
@@ -0,0 +1,69 @@
+import { toTxFeeValueUnit, estimateMaxCollaboratorFee } from './Fees'
+
+describe('toTxFeeValueUnit', () => {
+  it('should return correct unit', () => {
+    expect(toTxFeeValueUnit(undefined)).toBe(undefined)
+    expect(toTxFeeValueUnit(NaN)).toBe(undefined)
+    expect(toTxFeeValueUnit(-1)).toBe(undefined)
+    expect(toTxFeeValueUnit(0)).toBe(undefined)
+    expect(toTxFeeValueUnit(1.1)).toBe(undefined)
+    expect(toTxFeeValueUnit(1_001.1)).toBe(undefined)
+
+    expect(toTxFeeValueUnit(1)).toBe('blocks')
+    expect(toTxFeeValueUnit(999)).toBe('blocks')
+    expect(toTxFeeValueUnit(1_000)).toBe('blocks')
+
+    expect(toTxFeeValueUnit(1_001)).toBe('sats/kilo-vbyte')
+    expect(toTxFeeValueUnit(1001 + Math.round(Math.random() * 1_000_000))).toBe('sats/kilo-vbyte')
+    expect(toTxFeeValueUnit(Number.MAX_SAFE_INTEGER)).toBe('sats/kilo-vbyte')
+  })
+})
+
+describe('estimateMaxCollaboratorFee', () => {
+  it('should return estimate based on max fee', () => {
+    const data = {
+      amount: 100_000_000, // 1 btc
+      maxFeeAbs: 21_000,
+      maxFeeRel: 0.0002, // 0.02% = (20k sats)
+    }
+
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: -1 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 0 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 1 })).toBe(21_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 2 })).toBe(42_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 3 })).toBe(63_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 10 })).toBe(210_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 21_000 })).toBe(data.amount)
+  })
+
+  it('should return estimate based on rel fee', () => {
+    const data = {
+      amount: 100_000_000, // 1 btc
+      maxFeeAbs: 9_001,
+      maxFeeRel: 0.0001, // 0.01% = (10k sats)
+    }
+
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: -1 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 0 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 1 })).toBe(10_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 2 })).toBe(20_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 3 })).toBe(30_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 10 })).toBe(100_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 21_000 })).toBe(data.amount)
+  })
+
+  it('should return amount if max. fees are higher than amount', () => {
+    const data = {
+      amount: 21, // 21 sats
+      maxFeeAbs: 22,
+      maxFeeRel: 1, // 100%
+    }
+
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: -1 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 0 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 1 })).toBe(data.amount)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 2 })).toBe(data.amount)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 10 })).toBe(data.amount)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 21_000 })).toBe(data.amount)
+  })
+})