Repository: joinmarket-webui/jam
Pull request #531 (2022-10-04 09:02:10)

i18n(orderbook): order -> offer
Change phrasing in orderbook summary: "order" -> "offer".
The "orderbook" does not consist of "orders" but of "offers". A taker is someone that makes an "order" based on "offers".

e.g.
-  "42 orders by 21 counterparties" -> "42 offers by 21 counterparties"
- "Highlight my orders" -> "Highlight my offers"




Patch:
diff --git a/public/sprite.svg b/public/sprite.svg
index 389f5bd..f0224b6 100644
--- a/public/sprite.svg
+++ b/public/sprite.svg
@@ -336,4 +336,7 @@
     <symbol id="matrix" fill="currentColor" viewBox="0 0 30.844 31.989">
         <path d="m 0.936,0.732 v 30.52 H 3.13 v 0.732 H 0.095 V 0.004 h 3.034 v 0.732 z m 8.45,9.675 v 1.544 H 9.43 c 0.3845145,-0.563728 0.893229,-1.031731 1.487,-1.368 0.58,-0.323 1.245,-0.485 1.993,-0.485 0.72,0 1.377,0.14 1.972,0.42 0.595,0.279 1.047,0.771 1.355,1.477 0.338,-0.5 0.796,-0.941 1.377,-1.323 0.58,-0.383 1.266,-0.574 2.06,-0.574 0.602,0 1.16,0.074 1.674,0.22 0.514,0.148 0.954,0.383 1.322,0.707 0.366,0.323 0.653,0.746 0.859,1.268 0.205,0.522 0.308,1.15 0.308,1.887 v 7.633 H 20.71 v -6.464 c 0,-0.383 -0.015,-0.743 -0.044,-1.082 -0.02094,-0.307213 -0.10323,-0.607116 -0.242,-0.882 -0.133151,-0.250804 -0.335956,-0.457776 -0.584,-0.596 -0.257,-0.146 -0.606,-0.22 -1.047,-0.22 -0.44,0 -0.796,0.085 -1.068,0.253 -0.272,0.17 -0.485,0.39 -0.639,0.662 -0.159137,0.287282 -0.263572,0.601605 -0.308,0.927 -0.05195,0.346916 -0.07802,0.697216 -0.078,1.048 v 6.354 h -3.128 v -6.398 c 0,-0.338 -0.007,-0.673 -0.021,-1.004 -0.01135,-0.313657 -0.07488,-0.62323 -0.188,-0.916 -0.107952,-0.276617 -0.30042,-0.512127 -0.55,-0.673 -0.258,-0.168 -0.636,-0.253 -1.135,-0.253 -0.198129,0.0083 -0.394382,0.04195 -0.584,0.1 -0.25838,0.07446 -0.498459,0.201822 -0.705,0.374 -0.228,0.184 -0.422,0.449 -0.584,0.794 -0.161,0.346 -0.242,0.798 -0.242,1.357 v 6.619 H 6.434 v -11.41 z m 20.712,20.84 V 0.727 h -2.194 v -0.732 h 3.035 v 31.98 h -3.035 v -0.732 z"/>
     </symbol>
+    <symbol id="block" viewBox="0 0 24 24" fill="currentColor">
+        <path fill-rule="evenodd" d="M21 8.344a.73.73 0 00-.364-.645l-7.494-4.395a2.21 2.21 0 00-2.23-.003L3.368 7.698a.73.73 0 00-.365.646H3v7.43h.004a.73.73 0 00.376.622l7.563 4.242a2.21 2.21 0 002.167-.003l7.515-4.24a.73.73 0 00.375-.62v-7.43zm-16.236.564a.375.375 0 00-.368.653l6.359 3.588a2.585 2.585 0 002.551-.006l6.278-3.583a.375.375 0 10-.372-.651l-6.278 3.582c-.56.32-1.248.322-1.81.005l-6.36-3.588z" clip-rule="evenodd"></path>
+    </symbol>
 </svg>
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index e500206..d75d283 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -15,6 +15,7 @@ import * as Api from '../libs/JmWalletApi'
 import styles from './Earn.module.css'
 import { OrderbookOverlay } from './Orderbook'
 import Balance from './Balance'
+import { factorToPercentage, percentageToFactor } from '../utils'
 
 // 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.
@@ -73,20 +74,6 @@ const initialFormValues = (settings) => ({
     FORM_INPUT_DEFAULT_VALUES.minsize,
 })
 
-const percentageToFactor = (val, precision = 6) => {
-  // Value cannot just be divided
-  // e.g. ✗ 0.0027 / 100 == 0.000027000000000000002
-  // but: ✓ Number((0.0027 / 100).toFixed(6)) = 0.000027
-  return Number((val / 100).toFixed(precision))
-}
-
-const factorToPercentage = (val, precision = 6) => {
-  // Value cannot just be divided
-  // e.g. ✗ 0.000027 * 100 == 0.0026999999999999997
-  // but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027
-  return Number((val * 100).toFixed(precision))
-}
-
 const renderOrderType = (val, t) => {
   if (isAbsoluteOffer(val)) {
     return <rb.Badge bg="info">{t('earn.current.text_offer_type_absolute')}</rb.Badge>
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx
index cce79c1..5f00718 100644
--- a/src/components/Settings.jsx
+++ b/src/components/Settings.jsx
@@ -17,6 +17,7 @@ import { fetchFeatures } from '../libs/JamApi'
 import { routes } from '../constants/routes'
 import languages from '../i18n/languages'
 import styles from './Settings.module.css'
+import FeeConfigModal from './settings/FeeConfigModal'
 
 function SeedModal({ show = false, onHide }) {
   const { t } = useTranslation()
@@ -91,6 +92,7 @@ function SeedModal({ show = false, onHide }) {
 
 export default function Settings({ stopWallet }) {
   const [showingSeed, setShowingSeed] = useState(false)
+  const [showingFeeConfig, setShowingFeeConfig] = useState(false)
   const [lockingWallet, setLockingWallet] = useState(false)
   const [showConfirmLockModal, setShowConfirmLockModal] = useState(null)
   const [showLogsEnabled, setShowLogsEnabled] = useState(false)
@@ -235,6 +237,20 @@ export default function Settings({ stopWallet }) {
             </rb.Dropdown.Menu>
           </rb.Dropdown>
 
+          {currentWallet && (
+            <>
+              <rb.Button
+                variant="outline-dark"
+                className={styles['settings-btn']}
+                onClick={(e) => setShowingFeeConfig(true)}
+              >
+                <Sprite symbol="coins" width="24" height="24" />
+                {t('settings.show_fee_config')}
+              </rb.Button>
+              {showingFeeConfig && <FeeConfigModal show={showingFeeConfig} onHide={() => setShowingFeeConfig(false)} />}
+            </>
+          )}
+
           {currentWallet && showLogsEnabled && (
             <>
               <rb.Button variant="outline-dark" className={styles['settings-btn']} onClick={() => setShowingLogs(true)}>
diff --git a/src/components/settings/FeeConfigModal.module.css b/src/components/settings/FeeConfigModal.module.css
new file mode 100644
index 0000000..94194ba
--- /dev/null
+++ b/src/components/settings/FeeConfigModal.module.css
@@ -0,0 +1,45 @@
+.feeConfigModal .accordionLoader {
+  height: 3.25rem;
+  margin: 1px 0;
+}
+
+.feeConfigModal form input:not([type='checkbox']) {
+  height: 3.5rem;
+}
+
+.feeConfigModal form :global .form-label {
+  margin-bottom: 0 !important;
+}
+.feeConfigModal form :global .form-label ~ .form-text {
+  display: block;
+  margin-top: 0 !important;
+  margin-bottom: 0.5rem !important;
+}
+
+.infoIcon {
+  border: 1px solid;
+  border-radius: 50%;
+}
+
+.inputGroupText {
+  width: 5ch;
+  display: inline-flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 1.2rem;
+}
+
+.modalFooter .buttonContainer {
+  width: 100%;
+  display: flex !important;
+  justify-content: center !important;
+  gap: 1rem;
+  background-color: transparent !important;
+}
+
+.modalFooter .buttonContainer :global .btn {
+  flex-grow: 1;
+  min-height: 2.8rem;
+  font-weight: 500;
+  border-color: none !important;
+}
diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx
new file mode 100644
index 0000000..b2cc027
--- /dev/null
+++ b/src/components/settings/FeeConfigModal.tsx
@@ -0,0 +1,562 @@
+import { forwardRef, useRef, useCallback, useEffect, useState } from 'react'
+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 Sprite from '../Sprite'
+import styles from './FeeConfigModal.module.css'
+import SegmentedTabs from '../SegmentedTabs'
+
+type SatsPerKiloVByte = number
+
+const TX_FEES_BLOCKS_MIN = 1
+const TX_FEES_BLOCKS_MAX = 1_000
+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
+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 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>
+  onSubmit: (values: FeeValues, txFeesUnit: TxFeeValueUnit) => void
+}
+
+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'
+    )
+
+    return (
+      <Formik
+        initialValues={initialValues}
+        validate={(values) => validate(values, txFeesUnit)}
+        onSubmit={(values) => onSubmit(values, txFeesUnit)}
+      >
+        {({ handleSubmit, setFieldValue, handleBlur, validateForm, values, touched, errors, isSubmitting }) => (
+          <rb.Form ref={ref} onSubmit={handleSubmit} noValidate lang={i18n.resolvedLanguage || i18n.language}>
+            <rb.Accordion flush>
+              <rb.Accordion.Item eventKey="0">
+                <rb.Accordion.Header>
+                  <span
+                    className={classNames({
+                      'text-danger': !!errors.tx_fees || !!errors.tx_fees_factor,
+                    })}
+                  >
+                    {t('settings.fees.title_general_fee_settings')}
+                  </span>
+                </rb.Accordion.Header>
+                <rb.Accordion.Body>
+                  <rb.Form.Label>{t('settings.fees.label_tx_fees')}</rb.Form.Label>
+                  {txFeesUnit && (
+                    <rb.Form.Group className="my-2 d-flex justify-content-center" controlId="offertype">
+                      <SegmentedTabs
+                        name="txFeesUnit"
+                        tabs={[
+                          {
+                            label: t('settings.fees.radio_tx_fees_blocks'),
+                            value: 'blocks',
+                          },
+                          {
+                            label: t('settings.fees.radio_tx_fees_satspervbyte'),
+                            value: 'sats/kilo-vbyte',
+                          },
+                        ]}
+                        onChange={(tab) => {
+                          const value = tab.value as TxFeeValueUnit
+                          setTxFeesUnit(value)
+
+                          if (values.tx_fees) {
+                            if (value === 'sats/kilo-vbyte') {
+                              setFieldValue('tx_fees', values.tx_fees * 1_000, false)
+                            } else {
+                              setFieldValue('tx_fees', Math.round(values.tx_fees / 1_000), false)
+                            }
+                          }
+                          setTimeout(() => validateForm(), 4)
+                        }}
+                        initialValue={txFeesUnit}
+                        disabled={isSubmitting}
+                      />
+                    </rb.Form.Group>
+                  )}
+                  <rb.Form.Text>
+                    {t(
+                      txFeesUnit === 'sats/kilo-vbyte'
+                        ? 'settings.fees.description_tx_fees_satspervbyte'
+                        : 'settings.fees.description_tx_fees_blocks'
+                    )}
+                  </rb.Form.Text>
+                  <rb.Form.Group controlId="tx_fees" className="mb-4">
+                    <rb.InputGroup hasValidation>
+                      <rb.InputGroup.Text id="txFees-addon1">
+                        {txFeesUnit === 'sats/kilo-vbyte' ? (
+                          <>
+                            <Sprite symbol="sats" width="24" height="24" />/ vB
+                          </>
+                        ) : (
+                          <Sprite symbol="block" width="24" height="24" name="Block" />
+                        )}
+                      </rb.InputGroup.Text>
+
+                      {txFeesUnit === 'sats/kilo-vbyte' ? (
+                        <rb.Form.Control
+                          aria-label={t('settings.fees.label_tx_fees')}
+                          className="slashed-zeroes"
+                          name="tx_fees"
+                          type="number"
+                          placeholder="1"
+                          value={isValidNumber(values.tx_fees) ? values.tx_fees! / 1_000 : ''}
+                          disabled={isSubmitting}
+                          onBlur={handleBlur}
+                          onChange={(e) => {
+                            const value = parseFloat(e.target.value)
+                            setFieldValue('tx_fees', value * 1_000, true)
+                          }}
+                          isValid={touched.tx_fees && !errors.tx_fees}
+                          isInvalid={touched.tx_fees && !!errors.tx_fees}
+                          min={TX_FEES_SATSPERKILOVBYTE_MIN / 1_000}
+                          max={TX_FEES_SATSPERKILOVBYTE_MAX / 1_000}
+                          step={0.001}
+                        />
+                      ) : (
+                        <rb.Form.Control
+                          aria-label={t('settings.fees.label_tx_fees')}
+                          className="slashed-zeroes"
+                          name="tx_fees"
+                          type="number"
+                          placeholder="1"
+                          value={isValidNumber(values.tx_fees) ? values.tx_fees : ''}
+                          disabled={isSubmitting}
+                          onBlur={handleBlur}
+                          onChange={(e) => {
+                            const value = parseInt(e.target.value, 10)
+                            setFieldValue('tx_fees', value, true)
+                          }}
+                          isValid={touched.tx_fees && !errors.tx_fees}
+                          isInvalid={touched.tx_fees && !!errors.tx_fees}
+                          min={TX_FEES_BLOCKS_MIN}
+                          max={TX_FEES_BLOCKS_MAX}
+                          step={1}
+                        />
+                      )}
+                      <rb.Form.Control.Feedback type="invalid">{errors.tx_fees}</rb.Form.Control.Feedback>
+                    </rb.InputGroup>
+                  </rb.Form.Group>
+
+                  <rb.Form.Group controlId="tx_fees_factor" className="mb-4">
+                    <rb.Form.Label>
+                      {t('settings.fees.label_tx_fees_factor', {
+                        fee: isValidNumber(values.tx_fees_factor)
+                          ? `(${factorToPercentage(values.tx_fees_factor!)}%)`
+                          : '',
+                      })}
+                    </rb.Form.Label>
+                    <rb.Form.Text>{t('settings.fees.description_tx_fees_factor')}</rb.Form.Text>
+                    <rb.InputGroup hasValidation>
+                      <rb.InputGroup.Text id="txFeesFactor-addon1" className={styles.inputGroupText}>
+                        %
+                      </rb.InputGroup.Text>
+                      <rb.Form.Control
+                        aria-label={t('settings.fees.label_tx_fees_factor', {
+                          fee: isValidNumber(values.tx_fees_factor)
+                            ? `(${factorToPercentage(values.tx_fees_factor!)}%)`
+                            : '',
+                        })}
+                        className="slashed-zeroes"
+                        name="tx_fees_factor"
+                        type="number"
+                        placeholder="0"
+                        value={isValidNumber(values.tx_fees_factor) ? factorToPercentage(values.tx_fees_factor!) : ''}
+                        disabled={isSubmitting}
+                        onBlur={handleBlur}
+                        onChange={(e) => {
+                          const value = parseFloat(e.target.value)
+                          setFieldValue('tx_fees_factor', percentageToFactor(value), true)
+                        }}
+                        isValid={touched.tx_fees_factor && !errors.tx_fees_factor}
+                        isInvalid={touched.tx_fees_factor && !!errors.tx_fees_factor}
+                        min={factorToPercentage(TX_FEES_FACTOR_MIN)}
+                        max={factorToPercentage(TX_FEES_FACTOR_MAX)}
+                        step={0.01}
+                      />
+                      <rb.Form.Control.Feedback type="invalid">{errors.tx_fees_factor}</rb.Form.Control.Feedback>
+                    </rb.InputGroup>
+                  </rb.Form.Group>
+                </rb.Accordion.Body>
+              </rb.Accordion.Item>
+              <rb.Accordion.Item eventKey="1">
+                <rb.Accordion.Header>
+                  <span
+                    className={classNames({
+                      'text-danger': !!errors.max_cj_fee_abs || !!errors.max_cj_fee_rel,
+                    })}
+                  >
+                    {t('settings.fees.title_max_cj_fee_settings')}
+                  </span>
+                </rb.Accordion.Header>
+                <rb.Accordion.Body>
+                  <rb.Form.Text className="d-flex align-items-center mb-4">
+                    <Sprite className={classNames(styles.infoIcon, 'me-1')} symbol="info" width="18" height="18" />
+                    {t('settings.fees.subtitle_max_cj_fee')}
+                  </rb.Form.Text>
+                  <rb.Form.Group controlId="max_cj_fee_abs" className="mb-4">
+                    <rb.Form.Label>{t('settings.fees.label_max_cj_fee_abs')}</rb.Form.Label>
+                    <rb.Form.Text>{t('settings.fees.description_max_cj_fee_abs')}</rb.Form.Text>
+                    <rb.InputGroup hasValidation>
+                      <rb.InputGroup.Text id="maxCjFeeAbs-addon1" className={styles.inputGroupText}>
+                        <Sprite symbol="sats" width="24" height="24" />
+                      </rb.InputGroup.Text>
+                      <rb.Form.Control
+                        aria-label={t('settings.fees.label_max_cj_fee_abs')}
+                        className="slashed-zeroes"
+                        name="max_cj_fee_abs"
+                        type="number"
+                        placeholder="1"
+                        value={isValidNumber(values.max_cj_fee_abs) ? values.max_cj_fee_abs : ''}
+                        disabled={isSubmitting}
+                        onBlur={handleBlur}
+                        onChange={(e) => {
+                          const value = parseInt(e.target.value, 10)
+                          setFieldValue('max_cj_fee_abs', value, true)
+                        }}
+                        isValid={touched.max_cj_fee_abs && !errors.max_cj_fee_abs}
+                        isInvalid={touched.max_cj_fee_abs && !!errors.max_cj_fee_abs}
+                        min={CJ_FEE_ABS_MIN}
+                        max={CJ_FEE_ABS_MAX}
+                        step={1}
+                      />
+                      <rb.Form.Control.Feedback type="invalid">{errors.max_cj_fee_abs}</rb.Form.Control.Feedback>
+                    </rb.InputGroup>
+                  </rb.Form.Group>
+
+                  <rb.Form.Group controlId="max_cj_fee_rel" className="mb-4">
+                    <rb.Form.Label>
+                      {t('settings.fees.label_max_cj_fee_rel', {
+                        fee: isValidNumber(values.max_cj_fee_rel)
+                          ? `(${factorToPercentage(values.max_cj_fee_rel!)}%)`
+                          : '',
+                      })}
+                    </rb.Form.Label>
+                    <rb.Form.Text>{t('settings.fees.description_max_cj_fee_rel')}</rb.Form.Text>
+                    <rb.InputGroup hasValidation>
+                      <rb.InputGroup.Text id="maxCjFeeRel-addon1" className={styles.inputGroupText}>
+                        %
+                      </rb.InputGroup.Text>
+                      <rb.Form.Control
+                        aria-label={t('settings.fees.label_max_cj_fee_rel')}
+                        className="slashed-zeroes"
+                        name="max_cj_fee_rel"
+                        type="number"
+                        placeholder="0"
+                        value={isValidNumber(values.max_cj_fee_rel) ? factorToPercentage(values.max_cj_fee_rel!) : ''}
+                        disabled={isSubmitting}
+                        onBlur={handleBlur}
+                        onChange={(e) => {
+                          const value = parseFloat(e.target.value)
+                          setFieldValue('max_cj_fee_rel', percentageToFactor(value), true)
+                        }}
+                        isValid={touched.max_cj_fee_rel && !errors.max_cj_fee_rel}
+                        isInvalid={touched.max_cj_fee_rel && !!errors.max_cj_fee_rel}
+                        min={factorToPercentage(CJ_FEE_REL_MIN)}
+                        max={factorToPercentage(CJ_FEE_REL_MAX)}
+                        step={0.0001}
+                      />
+                      <rb.Form.Control.Feedback type="invalid">{errors.max_cj_fee_rel}</rb.Form.Control.Feedback>
+                    </rb.InputGroup>
+                  </rb.Form.Group>
+                </rb.Accordion.Body>
+              </rb.Accordion.Item>
+            </rb.Accordion>
+          </rb.Form>
+        )}
+      </Formik>
+    )
+  }
+)
+
+export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
+  const { t } = useTranslation()
+  const refreshConfigValues = useRefreshConfigValues()
+  const updateConfigValues = useUpdateConfigValues()
+  const [isLoading, setIsLoading] = useState(true)
+  const [isSubmitting, setIsSubmitting] = useState(false)
+  const [loadError, setLoadError] = useState(false)
+  const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>(undefined)
+  const [feeConfigValues, setFeeConfigValues] = useState<FeeValues | null>(null)
+  const formRef = useRef<HTMLFormElement>(null)
+
+  useEffect(() => {
+    const loadFeeValues = async (signal: AbortSignal) => {
+      setLoadError(false)
+      setIsLoading(true)
+
+      try {
+        const serviceConfig = await refreshConfigValues({
+          signal,
+          keys: Object.values(FEE_KEYS),
+        })
+
+        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) {
+          setIsLoading(false)
+          setLoadError(true)
+        }
+      }
+    }
+
+    const abortCtrl = new AbortController()
+    if (show) {
+      loadFeeValues(abortCtrl.signal)
+    } else {
+      setLoadError(false)
+      setSaveErrorMessage(undefined)
+    }
+
+    return () => {
+      abortCtrl.abort()
+    }
+  }, [show, refreshConfigValues])
+
+  const submit = async (feeValues: FeeValues, txFeesUnit: TxFeeValueUnit) => {
+    const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined)
+    if (!allValuesPresent) return
+
+    let adjustedTxFees = feeValues.tx_fees!
+    if (txFeesUnit === 'sats/kilo-vbyte') {
+      // There is one special case for value `tx_fees`:
+      // Users are allowed to specify the value in "sats/vbyte", but this might
+      // be interpreted by JM as "targeted blocks". This adaption makes sure
+      // that it is in fact closer to what the user actually expects, albeit it
+      // can be surprising that the value is slightly different as specified.
+      adjustedTxFees = Math.max(adjustedTxFees, TX_FEES_SATSPERKILOVBYTE_ADJUSTED_MIN)
+    }
+
+    const updates = [
+      {
+        key: FEE_KEYS.tx_fees,
+        value: String(adjustedTxFees),
+      },
+      {
+        key: FEE_KEYS.tx_fees_factor,
+        value: String(feeValues.tx_fees_factor),
+      },
+      {
+        key: FEE_KEYS.max_cj_fee_abs,
+        value: String(feeValues.max_cj_fee_abs),
+      },
+      {
+        key: FEE_KEYS.max_cj_fee_rel,
+        value: String(feeValues.max_cj_fee_rel),
+      },
+    ]
+
+    setSaveErrorMessage(undefined)
+    setIsSubmitting(true)
+    try {
+      await updateConfigValues({ updates })
+
+      setIsSubmitting(false)
+      onHide()
+    } catch (err) {
+      setIsSubmitting(false)
+      setSaveErrorMessage(
+        t('settings.fees.error_saving_fee_config_failed', {
+          reason: err instanceof Error ? err.message : 'Unknown',
+        })
+      )
+    }
+  }
+
+  const validate = useCallback(
+    (values: FeeValues, txFeesUnit: TxFeeValueUnit) => {
+      const errors = {} as FormikErrors<FeeValues>
+
+      if (
+        !isValidNumber(values.tx_fees_factor) ||
+        values.tx_fees_factor! < TX_FEES_FACTOR_MIN ||
+        values.tx_fees_factor! > TX_FEES_FACTOR_MAX
+      ) {
+        errors.tx_fees_factor = t('settings.fees.feedback_invalid_tx_fees_factor', {
+          min: `${factorToPercentage(TX_FEES_FACTOR_MIN)}%`,
+          max: `${factorToPercentage(TX_FEES_FACTOR_MAX)}%`,
+        })
+      }
+
+      if (txFeesUnit === 'sats/kilo-vbyte') {
+        if (
+          !isValidNumber(values.tx_fees) ||
+          values.tx_fees! < TX_FEES_SATSPERKILOVBYTE_MIN ||
+          values.tx_fees! > TX_FEES_SATSPERKILOVBYTE_MAX
+        ) {
+          errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_satspervbyte', {
+            min: (TX_FEES_SATSPERKILOVBYTE_MIN / 1_000).toLocaleString(undefined, {
+              maximumFractionDigits: Math.log10(1_000),
+            }),
+            max: (TX_FEES_SATSPERKILOVBYTE_MAX / 1_000).toLocaleString(undefined, {
+              maximumFractionDigits: Math.log10(1_000),
+            }),
+          })
+        }
+      } else {
+        if (
+          !isValidNumber(values.tx_fees) ||
+          values.tx_fees! < TX_FEES_BLOCKS_MIN ||
+          values.tx_fees! > TX_FEES_BLOCKS_MAX
+        ) {
+          errors.tx_fees = t('settings.fees.feedback_invalid_tx_fees_blocks', {
+            min: TX_FEES_BLOCKS_MIN.toLocaleString(),
+            max: TX_FEES_BLOCKS_MAX.toLocaleString(),
+          })
+        }
+      }
+
+      if (
+        !isValidNumber(values.max_cj_fee_abs) ||
+        values.max_cj_fee_abs! < CJ_FEE_ABS_MIN ||
+        values.max_cj_fee_abs! > CJ_FEE_ABS_MAX
+      ) {
+        errors.max_cj_fee_abs = t('settings.fees.feedback_invalid_max_cj_fee_abs', {
+          min: CJ_FEE_ABS_MIN.toLocaleString(),
+          max: CJ_FEE_ABS_MAX.toLocaleString(),
+        })
+      }
+
+      if (
+        !isValidNumber(values.max_cj_fee_rel) ||
+        values.max_cj_fee_rel! <= CJ_FEE_REL_MIN ||
+        values.max_cj_fee_rel! > CJ_FEE_REL_MAX
+      ) {
+        errors.max_cj_fee_rel = t('settings.fees.feedback_invalid_max_cj_fee_rel', {
+          min: `${factorToPercentage(CJ_FEE_REL_MIN)}%`,
+          max: `${factorToPercentage(CJ_FEE_REL_MAX)}%`,
+        })
+      }
+      return errors
+    },
+    [t]
+  )
+
+  const cancel = () => {
+    onHide()
+  }
+
+  return (
+    <rb.Modal
+      className={styles.feeConfigModal}
+      size="lg"
+      show={show}
+      onHide={onHide}
+      keyboard={false}
+      centered={true}
+      animation={true}
+    >
+      <rb.Modal.Header closeButton>
+        <rb.Modal.Title>{t('settings.fees.title')}</rb.Modal.Title>
+      </rb.Modal.Header>
+      <rb.Modal.Body>
+        <>
+          <div className="mb-4 small">{t('settings.fees.description')}</div>
+          {loadError && (
+            <rb.Alert variant="danger" className="w-100">
+              {t('settings.fees.error_loading_fee_config_failed')}
+            </rb.Alert>
+          )}
+          {isLoading ? (
+            <>
+              {Array(2)
+                .fill('')
+                .map((_, index) => {
+                  return (
+                    <rb.Placeholder key={index} as="div" animation="wave">
+                      <rb.Placeholder xs={12} className={styles.accordionLoader} />
+                    </rb.Placeholder>
+                  )
+                })}
+            </>
+          ) : (
+            <>
+              {feeConfigValues && (
+                <FeeConfigForm ref={formRef} initialValues={feeConfigValues} validate={validate} onSubmit={submit} />
+              )}
+            </>
+          )}
+        </>
+      </rb.Modal.Body>
+      <rb.Modal.Footer className={styles.modalFooter}>
+        {saveErrorMessage && (
+          <rb.Alert variant="danger" className="w-100">
+            {saveErrorMessage}
+          </rb.Alert>
+        )}
+        <div className={styles.buttonContainer}>
+          <rb.Button variant="light" onClick={cancel} className="d-flex justify-content-center align-items-center">
+            {t('settings.fees.text_button_cancel')}
+          </rb.Button>
+          <rb.Button
+            variant="dark"
+            type="submit"
+            className="d-flex justify-content-center align-items-center"
+            disabled={isLoading || isSubmitting}
+            onClick={() => formRef.current?.requestSubmit()}
+          >
+            {isSubmitting ? (
+              <>
+                <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
+                {t('settings.fees.text_button_submitting')}
+              </>
+            ) : (
+              t('settings.fees.text_button_submit')
+            )}
+          </rb.Button>
+        </div>
+      </rb.Modal.Footer>
+    </rb.Modal>
+  )
+}
diff --git a/src/context/ServiceConfigContext.tsx b/src/context/ServiceConfigContext.tsx
index 80d5a65..cf0bd64 100644
--- a/src/context/ServiceConfigContext.tsx
+++ b/src/context/ServiceConfigContext.tsx
@@ -8,27 +8,37 @@ interface JmConfigData {
   configvalue: string
 }
 
-type SectionKey = string
+export type SectionKey = string
 
-interface ServiceConfig {
+export interface ServiceConfig {
   [key: SectionKey]: Record<string, string | null>
 }
 
-interface ConfigKey {
+export interface ConfigKey {
   section: SectionKey
   field: string
 }
 
-interface ServiceConfigUpdate {
+export interface ServiceConfigUpdate {
   key: ConfigKey
   value: string
 }
 
 type LoadConfigValueProps = {
-  signal: AbortSignal
+  signal?: AbortSignal
   key: ConfigKey
 }
 
+type RefreshConfigValuesProps = {
+  signal?: AbortSignal
+  keys: ConfigKey[]
+}
+
+type UpdateConfigValuesProps = {
+  signal?: AbortSignal
+  updates: ServiceConfigUpdate[]
+}
+
 const configReducer = (state: ServiceConfig, obj: ServiceConfigUpdate): ServiceConfig => {
   const data = { ...state }
   data[obj.key.section] = { ...data[obj.key.section], [obj.key.field]: obj.value }
@@ -40,16 +50,13 @@ const fetchConfigValues = async ({
   wallet,
   configKeys,
 }: {
-  signal: AbortSignal
+  signal?: AbortSignal
   wallet: CurrentWallet
   configKeys: ConfigKey[]
 }) => {
   const { name: walletName, token } = wallet
   const fetches: Promise<ServiceConfigUpdate>[] = configKeys.map((configKey) => {
-    return Api.postConfigGet(
-      { walletName, token, signal },
-      { section: configKey.section.toString(), field: configKey.field }
-    )
+    return Api.postConfigGet({ walletName, token, signal }, { section: configKey.section, field: configKey.field })
       .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res)))
       .then((data: JmConfigData) => {
         return {
@@ -62,8 +69,36 @@ const fetchConfigValues = async ({
   return Promise.all(fetches)
 }
 
+const pushConfigValues = async ({
+  signal,
+  wallet,
+  updates,
+}: {
+  signal?: AbortSignal
+  wallet: CurrentWallet
+  updates: ServiceConfigUpdate[]
+}) => {
+  const { name: walletName, token } = wallet
+  const fetches: Promise<ServiceConfigUpdate>[] = updates.map((update) => {
+    return Api.postConfigSet(
+      { walletName, token, signal },
+      {
+        section: update.key.section,
+        field: update.key.field,
+        value: update.value,
+      }
+    )
+      .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res)))
+      .then((_) => update)
+  })
+
+  return Promise.all(fetches)
+}
+
 interface ServiceConfigContextEntry {
-  loadConfigValue: (props: LoadConfigValueProps) => Promise<ServiceConfigUpdate>
+  loadConfigValueIfAbsent: (props: LoadConfigValueProps) => Promise<ServiceConfigUpdate>
+  refreshConfigValues: (props: RefreshConfigValuesProps) => Promise<ServiceConfig>
+  updateConfigValues: (props: UpdateConfigValuesProps) => Promise<ServiceConfig>
 }
 
 const ServiceConfigContext = createContext<ServiceConfigContextEntry | undefined>(undefined)
@@ -72,17 +107,16 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => {
   const currentWallet = useCurrentWallet()
   const serviceConfig = useRef<ServiceConfig | null>(null)
 
-  const updateServiceConfig = useCallback(
-    async ({ signal, configKeys }: { signal: AbortSignal; configKeys: ConfigKey[] }) => {
+  const refreshConfigValues = useCallback(
+    async ({ signal, keys }: RefreshConfigValuesProps) => {
       if (!currentWallet) {
         throw new Error('Cannot load config: Wallet not present')
       }
 
-      const configUpdates = fetchConfigValues({ signal, wallet: currentWallet, configKeys })
-      return configUpdates
+      return fetchConfigValues({ signal, wallet: currentWallet, configKeys: keys })
         .then((updates) => updates.reduce(configReducer, serviceConfig.current || {}))
         .then((result) => {
-          if (!signal.aborted) {
+          if (!signal || !signal.aborted) {
             serviceConfig.current = result
             if (process.env.NODE_ENV === 'development') {
               console.debug('service config updated', serviceConfig.current)
@@ -108,14 +142,35 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => {
         }
       }
 
-      return updateServiceConfig({ signal, configKeys: [key] }).then((conf) => {
+      return refreshConfigValues({ signal, keys: [key] }).then((conf) => {
         return {
           key,
           value: conf[key.section][key.field],
         } as ServiceConfigUpdate
       })
     },
-    [updateServiceConfig]
+    [refreshConfigValues]
+  )
+
+  const updateConfigValues = useCallback(
+    async ({ signal, updates }: UpdateConfigValuesProps) => {
+      if (!currentWallet) {
+        throw new Error('Cannot load config: Wallet not present')
+      }
+
+      return pushConfigValues({ signal, wallet: currentWallet, updates })
+        .then((updates) => updates.reduce(configReducer, serviceConfig.current || {}))
+        .then((result) => {
+          if (!signal || !signal.aborted) {
+            serviceConfig.current = result
+            if (process.env.NODE_ENV === 'development') {
+              console.debug('service config updated', serviceConfig.current)
+            }
+          }
+          return result
+        })
+    },
+    [currentWallet]
   )
 
   useEffect(() => {
@@ -126,7 +181,13 @@ const ServiceConfigProvider = ({ children }: React.PropsWithChildren<{}>) => {
   }, [currentWallet])
 
   return (
-    <ServiceConfigContext.Provider value={{ loadConfigValue: loadConfigValueIfAbsent }}>
+    <ServiceConfigContext.Provider
+      value={{
+        loadConfigValueIfAbsent,
+        refreshConfigValues,
+        updateConfigValues,
+      }}
+    >
       {children}
     </ServiceConfigContext.Provider>
   )
@@ -137,7 +198,29 @@ const useLoadConfigValue = () => {
   if (context === undefined) {
     throw new Error('useLoadConfigValue must be used within a ServiceConfigProvider')
   }
-  return context.loadConfigValue
+  return context.loadConfigValueIfAbsent
+}
+
+const useRefreshConfigValues = () => {
+  const context = useContext(ServiceConfigContext)
+  if (context === undefined) {
+    throw new Error('useRefreshConfigValues must be used within a ServiceConfigProvider')
+  }
+  return context.refreshConfigValues
 }
 
-export { ServiceConfigContext, ServiceConfigProvider, useLoadConfigValue }
+const useUpdateConfigValues = () => {
+  const context = useContext(ServiceConfigContext)
+  if (context === undefined) {
+    throw new Error('useUpdateConfigValues must be used within a ServiceConfigProvider')
+  }
+  return context.updateConfigValues
+}
+
+export {
+  ServiceConfigContext,
+  ServiceConfigProvider,
+  useLoadConfigValue,
+  useRefreshConfigValues,
+  useUpdateConfigValues,
+}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 5422c46..87e2786 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -154,6 +154,7 @@
     "hide_seed": "Hide seed phrase",
     "reveal_seed": "Reveal seed phrase",
     "show_logs": "Show logs",
+    "show_fee_config": "Adjust fee limits",
     "button_lock_wallet": "Lock wallet",
     "button_locking_wallet": "Locking...",
     "button_switch_wallet": "Switch wallet",
@@ -168,7 +169,35 @@
     "jam_twitter": "Jam Twitter",
     "confirm_locking_modal_title": "Lock Wallet",
     "confirm_locking_modal_body_earn": "Earn is active. Locking the wallet will stop the service. You can close the browser window to let it run in the background.",
-    "confirm_locking_modal_body_jam": "A collaborative transaction is being executed. Locking the wallet will stop the service. You can close the browser window to let it run in the background."
+    "confirm_locking_modal_body_jam": "A collaborative transaction is being executed. Locking the wallet will stop the service. You can close the browser window to let it run in the background.",
+    "fees": {
+      "title": "Fee Limits",
+      "description": "Adjust mining fees and collaborator fees according to your needs. Mining fees relate to transaction priority and depend on mempool conditions. Collaborator fees relate to liquidity price and depend on market conditions. Total fees paid for each transaction depend on the amount of collaborators. Additional collaborators increase privacy, but also fees. These settings will be reset to default values when the JoinMarket service restarts, e.g. on a system reboot.",
+      "title_general_fee_settings": "Mining fees",
+      "radio_tx_fees_blocks": "Block target",
+      "radio_tx_fees_satspervbyte": "sats/vByte",
+      "label_tx_fees": "Transaction base fee",
+      "description_tx_fees_blocks": "When using the block target setting, Jam will use Bitcoin Core's fee estimation mechanism to set the base fee dynamically, depending on mempool activity. Default: 3.",
+      "description_tx_fees_satspervbyte": "Setting the base fee in sats/vByte will override the block target setting. Check your mempool to set this fee accordingly.",
+      "feedback_invalid_tx_fees_blocks": "Please provide a valid block target between {{ min }} and {{ max }}.",
+      "feedback_invalid_tx_fees_satspervbyte": "Please provide a valid transaction fee in sats/vByte between {{ min }} and {{ max }}.",
+      "label_tx_fees_factor": "Fee randomization",
+      "description_tx_fees_factor": "Random fees improve privacy. The percentage is to be understood as a +/- around the base fee. Example: If you set the base fee to 10 sats/vByte and the randomization to 30%, a value between 7 and 13 sats/vByte will be used. Default: 20%.",
+      "feedback_invalid_tx_fees_factor": "Please provide a valid fee randomization value between {{ min }} and {{ max }}.",
+      "title_max_cj_fee_settings": "Collaborator fees",
+      "subtitle_max_cj_fee": "Note: An offer will be rejected only if both limits are exceeded.",
+      "label_max_cj_fee_abs": "Absolute limit (per collaborator)",
+      "description_max_cj_fee_abs": "The maximum fee you are willing to pay per collaborator. Example: when set to 3,000 sats, the maximum fee in total would be 27,000 sats when using the default of 9 collaborators.",
+      "feedback_invalid_max_cj_fee_abs": "Please provide a valid maximum absolute fee in sats between {{ min }} and {{ max }}.",
+      "label_max_cj_fee_rel": "Relative limit (per collaborator)",
+      "description_max_cj_fee_rel": "The maximum fee you are willing to pay per collaborator, as a percentage of the transaction amount. Example: if you send 2 million sats and the maximum fee is set to 0.1%, you will at most pay 2,000 sats to a single collaborator.",
+      "feedback_invalid_max_cj_fee_rel": "Please provide a valid maximum relative fee between {{ min }} and {{ max }}.",
+      "text_button_cancel": "Cancel",
+      "text_button_submit": "Save",
+      "text_button_submitting": "Saving...",
+      "error_loading_fee_config_failed": "Error while loading fee config values. ",
+      "error_saving_fee_config_failed": "Error while saving fee config values. Reason: {{ reason }}"
+    }
   },
   "logs": {
     "title": "Logs",
@@ -421,10 +450,10 @@
   },
   "orderbook": {
     "title": "Orderbook",
-    "text_orderbook_summary_one": "{{ count }} order by {{ counterpartyCount }} conterparty",
-    "text_orderbook_summary_other": "{{ count }} orders by {{ counterpartyCount }} conterparties",
-    "text_orderbook_summary_filtered_one": "found {{ count }} order by {{ counterpartyCount }} conterparty",
-    "text_orderbook_summary_filtered_other": "found {{ count }} orders by {{ counterpartyCount }} conterparties",
+    "text_orderbook_summary_one": "{{ count }} offer by {{ counterpartyCount }} conterparty",
+    "text_orderbook_summary_other": "{{ count }} offers by {{ counterpartyCount }} conterparties",
+    "text_orderbook_summary_filtered_one": "found {{ count }} offer by {{ counterpartyCount }} conterparty",
+    "text_orderbook_summary_filtered_other": "found {{ count }} offers by {{ counterpartyCount }} conterparties",
     "alert_empty_orderbook": "Orderbook is empty",
     "error_loading_orderbook_failed": "Error while loading the orderbook. Your current local setup might not support fetching the orderbook. Reason: {{ reason }}",
     "text_offer_type_absolute": "absolute",
@@ -432,7 +461,7 @@
     "label_search": "Search",
     "placeholder_search": "Search",
     "text_orderbook_filter_count": "{{ filterCount }} entries match given search criteria",
-    "label_highlight_own_orders": "Highlight my orders",
+    "label_highlight_own_orders": "Highlight my offers",
     "table": {
       "heading_type": "Type",
       "heading_counterparty": "Counterparty",
diff --git a/src/index.css b/src/index.css
index 6a76392..da637a5 100644
--- a/src/index.css
+++ b/src/index.css
@@ -535,21 +535,14 @@ h2 {
   cursor: not-allowed;
 }
 
-/* Account Overlay */
-
-.accordion-header .accordion-button:not(.collapsed) {
-  /* Disable coloring for expanded headers */
-  color: var(--bs-black);
-  background-color: transparent;
-}
-
-.accordion-header .accordion-button:not(.collapsed)::after {
-  /* Disable coloring for expanded headers */
-  background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23212529%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e');
-}
-
-:root[data-theme='dark'] .accordion-header .accordion-button:not(.collapsed)::after {
-  background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23fff%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e');
+.accordion {
+  --bs-accordion-color: var(--bs-black);
+  --bs-accordion-active-color: var(--bs-black);
+  --bs-accordion-bg: transparent;
+  --bs-accordion-active-bg: transparent;
+  --bs-accordion-btn-color: var(--bs-black);
+  --bs-accordion-btn-icon: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23212529%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e');
+  --bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon);
 }
 
 /* Theme overrides */
@@ -563,6 +556,14 @@ h2 {
   --bs-black: #000;
 }
 
+:root[data-theme='dark'] .accordion {
+  --bs-accordion-color: var(--bs-white);
+  --bs-accordion-active-color: var(--bs-white);
+  --bs-accordion-btn-color: var(--bs-white);
+  --bs-accordion-btn-icon: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23fff%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e');
+  --bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon);
+}
+
 :root[data-theme='dark'] a.nav-link.active {
   border-color: var(--bs-white) !important;
 }
@@ -590,19 +591,6 @@ h2 {
   border-color: var(--bs-gray-dark) !important;
 }
 
-:root[data-theme='dark'] .accordion {
-  --bs-accordion-color: var(--bs-white);
-  --bs-accordion-bg: transparent;
-}
-
-:root[data-theme='dark'] .accordion-button {
-  color: var(--bs-accordion-color);
-}
-
-:root[data-theme='dark'] .accordion-button.collapsed::after {
-  background-image: url('data:image/svg+xml,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 16 16%27 fill=%27%23fff%27%3e%3cpath fill-rule=%27evenodd%27 d=%27M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z%27/%3e%3c/svg%3e');
-}
-
 :root[data-theme='dark'] .btn-outline-dark {
   color: var(--bs-white);
   border-color: var(--bs-gray-800);
diff --git a/src/libs/JmWalletApi.ts b/src/libs/JmWalletApi.ts
index 51ec8ff..44f6aec 100644
--- a/src/libs/JmWalletApi.ts
+++ b/src/libs/JmWalletApi.ts
@@ -97,6 +97,12 @@ interface FreezeRequest {
 interface ConfigSetRequest {
   section: string
   field: string
+  value: string
+}
+
+interface ConfigGetRequest {
+  section: string
+  field: string
 }
 
 interface StartSchedulerRequest {
@@ -372,12 +378,24 @@ const getSchedule = async ({ token, signal, walletName }: WalletRequestContext)
   })
 }
 
+/**
+ * Change a config variable (for the duration of this backend daemon process instance).
+ */
+const postConfigSet = async ({ token, signal, walletName }: WalletRequestContext, req: ConfigSetRequest) => {
+  return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletName)}/configset`, {
+    method: 'POST',
+    headers: { ...Helper.buildAuthHeader(token) },
+    body: JSON.stringify(req),
+    signal,
+  })
+}
+
 /**
  * Get the value of a specific config setting. Note that values are always returned as string.
  *
  * @returns an object with property `configvalue` as string
  */
-const postConfigGet = async ({ token, signal, walletName }: WalletRequestContext, req: ConfigSetRequest) => {
+const postConfigGet = async ({ token, signal, walletName }: WalletRequestContext, req: ConfigGetRequest) => {
   return await fetch(`${basePath()}/v1/wallet/${encodeURIComponent(walletName)}/configget`, {
     method: 'POST',
     headers: { ...Helper.buildAuthHeader(token) },
@@ -411,6 +429,7 @@ export {
   getWalletUtxos,
   getYieldgenReport,
   postFreeze,
+  postConfigSet,
   postConfigGet,
   getWalletSeed,
   postSchedulerStart,
diff --git a/src/utils.ts b/src/utils.ts
index d353e53..62fa6d6 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -46,3 +46,17 @@ export const shortenStringMiddle = (value: string, chars = 8, separator = '…')
   }
   return `${value.substring(0, prefixLength)}${separator}${value.substring(value.length - prefixLength)}`
 }
+
+export const percentageToFactor = (val: number, precision = 6) => {
+  // Value cannot just be divided
+  // e.g. ✗ 0.0027 / 100 == 0.000027000000000000002
+  // but: ✓ Number((0.0027 / 100).toFixed(6)) = 0.000027
+  return Number((val / 100).toFixed(precision))
+}
+
+export const factorToPercentage = (val: number, precision = 6) => {
+  // Value cannot just be multiplied
+  // e.g. ✗ 0.000027 * 100 == 0.0026999999999999997
+  // but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027
+  return Number((val * 100).toFixed(precision))
+}


Test patch:
diff --git a/src/utils.test.ts b/src/utils.test.ts
index d7276d9..50127a8 100644
--- a/src/utils.test.ts
+++ b/src/utils.test.ts
@@ -1,4 +1,4 @@
-import { shortenStringMiddle } from './utils'
+import { shortenStringMiddle, percentageToFactor, factorToPercentage } from './utils'
 
 describe('shortenStringMiddle', () => {
   it('should shorten string in the middle', () => {
@@ -17,3 +17,55 @@ describe('shortenStringMiddle', () => {
     expect(shortenStringMiddle('0123456789abcdef', 32)).toBe('0123456789abcdef')
   })
 })
+
+describe('factorToPercentage/percentageToFactor', () => {
+  describe('factorToPercentage', () => {
+    it('should turn factor to percentage', () => {
+      expect(factorToPercentage(NaN)).toBe(NaN)
+      expect(factorToPercentage(-1)).toBe(-100)
+      expect(factorToPercentage(0)).toBe(0)
+      expect(factorToPercentage(0.0027)).toBe(0.27)
+      expect(factorToPercentage(0.0027, 1)).toBe(0.3)
+      expect(factorToPercentage(0.0027, 0)).toBe(0)
+      expect(factorToPercentage(1 / 3, 3)).toBe(33.333)
+      expect(factorToPercentage(1 / 3, 8)).toBe(33.33333333)
+      expect(factorToPercentage(2 / 3, 10)).toBe(66.6666666667)
+      expect(factorToPercentage(0.7)).toBe(70)
+      expect(factorToPercentage(1)).toBe(100)
+    })
+  })
+
+  describe('percentageToFactor', () => {
+    it('should turn percentage to factor', () => {
+      expect(percentageToFactor(NaN)).toBe(NaN)
+      expect(percentageToFactor(-1)).toBe(-0.01)
+      expect(percentageToFactor(0)).toBe(0)
+      expect(percentageToFactor(0.0027)).toBe(0.000027)
+      expect(percentageToFactor(0.0027, 5)).toBe(0.00003)
+      expect(percentageToFactor(0.0027, 4)).toBe(0)
+      expect(percentageToFactor(1 / 3, 3)).toBe(0.003)
+      expect(percentageToFactor(1 / 3, 8)).toBe(0.00333333)
+      expect(percentageToFactor(2 / 3, 10)).toBe(0.0066666667)
+      expect(percentageToFactor(0.7)).toBe(0.007)
+      expect(percentageToFactor(1)).toBe(0.01)
+      expect(percentageToFactor(33)).toBe(0.33)
+      expect(percentageToFactor(100)).toBe(1)
+      expect(percentageToFactor(233.7)).toBe(2.337)
+    })
+  })
+
+  it('functions are inverse', () => {
+    const testInverse = (val: number) => percentageToFactor(factorToPercentage(val))
+
+    expect(testInverse(NaN)).toBe(NaN)
+    expect(testInverse(0)).toBe(0)
+    expect(testInverse(0.0027)).toBe(0.0027)
+    expect(testInverse(0.7)).toBe(0.7)
+    expect(testInverse(1)).toBe(1)
+    expect(testInverse(1 / 3)).toBe(0.333333)
+    expect(testInverse(2 / 3)).toBe(0.666667)
+    expect(testInverse(33)).toBe(33)
+    expect(testInverse(100)).toBe(100)
+    expect(testInverse(233.7)).toBe(233.7)
+  })
+})