Repository: joinmarket-webui/jam
Pull request #522 (2022-09-27 16:48:34)

feat: basic fee settings
Partly addresses #518.

This PR is a first attempt to let users adapt some (not all) fee related settings:
Namely:
- [`tx_fees`](https://github.com/joinmarket-webui/jam/blob/v0.1.0/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg#L132-L141)
- [`tx_fees_factor`](https://github.com/joinmarket-webui/jam/blob/v0.1.0/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg#L143-L147)
- [`max_cj_fee_abs`](https://github.com/joinmarket-webui/jam/blob/v0.1.0/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg#L175-L181)
- [`max_cj_fee_rel`](https://github.com/joinmarket-webui/jam/blob/v0.1.0/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg#L183-L189)

Please help with the fee settings description if you can. Most of it is taken from JM config file comments with just minor adaptions. Any improvement is highly appreciated.

Questions on arbitrarily chosen min/max values (mainly to prevent unintended inputs):
- Min value of `tx_fees_factor`: Current implementation has min. of 10%. Should values below be allowed? e.g. zero means, that no fee randomization is done. [Zero is also handled fine by JM](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/babad1963992965e933924b6c306ad9da89989e0/jmclient/jmclient/blockchaininterface.py#L465-L483
).
- Max value of `max_cj_fee_rel`: Currently max of `50%`. Increase/decrease?
- Max value of `tx_fees`: Currently max of `100 sats/vbyte`. Increase/decrease? No upper limit? There is a config value `absurd_fee_per_kb` which translates to `350 sats/vbyte` by default.. maybe take this settings value as an upper limit?

## :camera_flash: 
<img src="https://user-images.githubusercontent.com/3358649/192617530-19ffdb03-cf8c-4c65-bee2-b3a29a90900c.png" width=400 />
<img src="https://user-images.githubusercontent.com/3358649/192617740-b9369dad-9d44-4dc9-9b4f-3e994c8afb49.png" width=400 />
<img src="https://user-images.githubusercontent.com/3358649/192617857-15633250-40d1-48c4-8aa0-8523a1ec0203.png" width=400 />
<img src="https://user-images.githubusercontent.com/3358649/192618025-6778d3f9-d5b0-4b68-9cda-2a4fbe2d4425.png" width=400 />
<img src="https://user-images.githubusercontent.com/3358649/192618128-829031d0-afe4-4008-91f9-c85533d6541a.png" width=400 />


#### Tests
- setting tx fee rate manually to `1.12 sats/vbyte` with 12% rate variance:
```
2022-09-26 03:01:58,174 [INFO]  Using this manually set tx feerate (randomized for privacy): 1190 sat/vkB (1.1 sat/vB).
```
- setting tx fee rate manually to `42 blocks`:
```
2022-09-26 03:41:52,464 [INFO]  Using bitcoin network feerate for 42 block confirmation target (randomized for privacy): 10000 sat/vkB (10.0 sat/vB)
```
- setting `max_cj_fee_abs` to `249 sats`) (below the default order with abs fee 250sats) and `max_cj_fee_rel` to `0.0002%` to trigger an error when attempting a collaborative tx with `123456789 sats`:
```
2022-09-28 10:47:45,191 [INFO]  INFO:Received offers from joinmarket pit
2022-09-28 10:47:45,192 [WARNING]  ERROR not enough liquidity in the orderbook n=1 suitable-counterparties=0 amount=27300 totalorders=0
2022-09-28 10:47:45,193 [INFO]  Taker not continuing after receipt of orderbook
2022-09-28 10:47:45,193 [INFO]  Coinjoin did not complete successfully.
```
   - after increasing `max_cj_fee_abs` to `251sats`: Success!


dergigi: Some questions that came up during my tests:

- ~~Why can't I set it to 1 sat/vByte?~~
- Why link the sats/vByte fee to the randomized percentage? When set to 20% I have to set the fee to `1.2`, which is very weird. It would be much more intuitive to just set it to `1` and the maximum of `1.2` is implicit in the 20% setting. Set the minimum, percentage defines the maximum.

Validation issues:

- `1.2` works, but `1,2` doesn't. We should make sure that inputs are i18n agnostic
- I can't set min abs fee to `0` but I can set it to`0.00000000001` which is actually `0`

JoinMarket issues:

> Both limits given in this section, relative and absolute, must be exceeded in order to not consider a certain offer.

I trust that this is true, but it is very weird behavior, isn't it? I would assume that if I set limits, breaking either limit would be a deal-breaker. Example: I don't ever want to pay more than a million sats, and I don't ever want to pay more than 10% in fees. If the order is large enough, a small percentage might be way more than a million sats, which makes one of the two rules I set obsolete.

Just thinking out loud here, not sure if I'm the weird one or if the way that JoinMarket works is the weird one.
dergigi: > Current implementation has min. of 10%. Should values below be allowed? e.g. zero means, that no fee randomization is done. [Zero is also handled fine by JM](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/babad1963992965e933924b6c306ad9da89989e0/jmclient/jmclient/blockchaininterface.py#L465-L483).

Yes. We should allow 0%. Personally, I would want to be able to set everything to 1 sat/vByte if the mempool is empty.
theborakompanioni: > Some questions that came up during my tests:
> 
>     * ~Why can't I set it to 1 sat/vByte?~
> 
>     * Why link the sats/vByte fee to the randomized percentage? When set to 20% I have to set the fee to `1.2`, which is very weird. It would be much more intuitive to just set it to `1` and the maximum of `1.2` is implicit in the 20% setting. Set the minimum, percentage defines the maximum.

Valid. Taken into account, that JM respects the `mempoolminfee` value of Bitcoin Core, even with variance applied, it will not go down below this value. Other than that, 1 sats/vByte or 1000 sats/kilo-vB (which is the actual value to set in the config), is still not possible, because a value between [1, 1000] will be interpreted as "block target". Everything above (>=1001) is treated as fee per kilo-vByte. The current approach stems [from suggestion here](https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1259638764).

I guess 1 sats/vbyte can be transformed to 1.001 sats/vbyte as the one and only special case. It will surprise user, though, but your are right that it feels better than not allowing it.

> 
> Validation issues:
> 
>     * `1.2` works, but `1,2` doesn't. We should make sure that inputs are i18n agnostic

I have never seen any form behave like that - is this really an issue?

>     * I can't set min abs fee to `0` but I can set it to`0.00000000001` which is actually `0`

Thanks! Will be fixed.

> 
> **Edit:** I think I answered this myself: the absolute fee is per maker, the relative fee is in total. That makes sense, and with that the weird condition makes sense too. You don't want to overpay a single maker (hence the absolute limit) and you don't want to overpay in total (hence the relative limit).
> 
> **Edit 2:** But the conditional in the way you phrased it still doesn't make sense, because it would override one of the limits. I don't want to overpay, period! Hence it should be:
> 
> > If an offer exceeds either of these limits it will be declined.
> 
> At least in my head that makes sense. Maybe I'm still stupid.

It is taken directly from the [JM config](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.8/jmclient/jmclient/configure.py#L314-L318):
> 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.

But yes, it is phrased in a confusing way and should definitely be rephrased.

Thanks for all the inputs. Will fix everything addressed asap.


PulpCattel: @dergigi 

>  I think I answered this myself: the absolute fee is per maker, the relative fee is in total. That makes sense, and with that the weird condition makes sense too. You don't want to overpay a single maker (hence the absolute limit) and you don't want to overpay in total (hence the relative limit).

Where did you get this idea? Both limits are checked for each individual offer.

The code is here https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/jmclient/jmclient/support.py#L243-L268

> Example: I don't ever want to pay more than a million sats, and I don't ever want to pay more than 10% in fees. If the order is large enough, a small percentage might be way more than a million sats, which makes one of the two rules I set obsolete.

I think the way to read these settings is: I'm fine paying (to each maker) either at most X sats or at most X% of the CJ amount. In your example, if I'm mixing a big amount where the abs fee is > than 1kk sats, but the % is < than 10%, then I'm telling the software it's fine, it's inside the allowed range.
dergigi: > Where did you get this idea? Both limits are checked for each individual offer.
>
> The code is here https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/jmclient/jmclient/support.py#L243-L268

Thanks, should've checked the code right away. Was a long day.

> I think the way to read these settings is: I'm fine paying (to each maker) either at most X sats or at most X% of the CJ amount.

That makes sense, thank you again. 
PulpCattel: BTW, not saying it's the best possible approach, only that is the current implement one AFAICT. I guess both approaches can have merits.
dergigi: > a value between [1, 1000] will be interpreted as "block target"

Oh my 😅

> I guess 1 sats/vbyte can be transformed to 1001 sats/kilo-vbyte as the one and only special case. It will surprise user, though, but your are right that it feels better than not allowing it.

I'd be for that. To me it would be more surprising that I can't set it to 1 sat/vB.

> I have never seen any form behave like that - is this really an issue?

Every single calculator app will parse whatever comma-style you use as a comma, no matter if you press `.` or `,`. It's not a critical issue, just annoying. At least we want to be consistent, I tried to input what was suggested as an example (`1,2`) and it failed, because it should've been `1.2`.

I'll have another go at improving the wording a bit once the other issues are fixed.
theborakompanioni: > I'll have another go at improving the wording a bit once the other issues are fixed.

@dergigi Should be ready for a re-review now. Please adapt the phrases to whatever you think fits best.

> > I guess 1 sats/vbyte can be transformed to 1001 sats/kilo-vbyte as the one and only special case. It will surprise user, though, but your are right that it feels better than not allowing it.
> 
> I'd be for that. To me it would be more surprising that I can't set it to 1 sat/vB.

Adapted! Thx!  Still testing though (and waiting for feedback on https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1261809147) - but seems good so far.

> > I have never seen any form behave like that - is this really an issue?
> 
> Every single calculator app will parse whatever comma-style you use as a comma, no matter if you press `.` or `,`. It's not a critical issue, just annoying. At least we want to be consistent, I tried to input what was suggested as an example (`1,2`) and it failed, because it should've been `1.2`.
> 

Sorry.. you know.. "don't trust, verify" and all that.. [Gnome Calculator](https://wiki.gnome.org/Apps/Calculator) certainly does not do it. I have not addressed this as of yet, as this is the standard behaviour of html input elements with `type=number`.


dergigi: I realize that JoinMarket uses the same parameter for "block target" and "sats/vByte", but it feels weird that one overrides the other in the interface.

Example: if I set the block target to 3 blocks, switch to sats/vByte, it will be set to 3 sats/vByte. If I change that now to 10 sats/vByte and switch back to the block target setting, the block target is now set to 10 blocks.

All that being said, I'm tempted to merge this soon and make these kinds of improvements in follow-up PRs.
dergigi: Note to self: we should mention the defaults of all values in the interface.

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..5c5f3fc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,31 @@
+---
+name: Bug report
+about: Create a report to help us improve
+title: ''
+labels: 'bug'
+assignees: ''
+
+---
+
+**Expected behavior**
+
+A clear and concise description of what you expected to happen.
+
+**Actual behavior**
+
+A clear and concise description of what the bug is and what actually happens.
+
+**Steps to reproduce the problem**
+  1.
+  2.
+  3.
+
+**Specifications**
+  - Version:
+  - Platform:
+  - Browser:
+
+**Additional context**
+
+Add any other context about the problem here to help explain your problem.
+e.g. error logs or screenshots if applicable. Make sure to remove any confidential information before sharing screenshots or logs.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..8119ddb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,24 @@
+---
+name: Feature request
+about: Suggest an idea
+title: ''
+labels: 'enhancement'
+assignees: ''
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+
+A clear and concise description of what the problem is. Example: I'm always frustrated when [...]
+
+**Describe the solution you'd like**
+
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+
+A clear and concise description of any alternative solutions or features you've considered.
+
+**Additional context**
+
+Add any other context or screenshots about the feature request here.
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/Jam.jsx b/src/components/Jam.jsx
index bd447fe..8642806 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -45,7 +45,12 @@ export default function Jam() {
 
   const [alert, setAlert] = useState(null)
   const [isLoading, setIsLoading] = useState(true)
-  const [collaborativeOperationRunning, setCollaborativeOperationRunning] = useState(false)
+  const [isWaitingSchedulerStart, setIsWaitingSchedulerStart] = useState(false)
+  const [isWaitingSchedulerStop, setIsWaitingSchedulerStop] = useState(false)
+  const collaborativeOperationRunning = useMemo(
+    () => serviceInfo?.coinjoinInProgress || serviceInfo?.makerRunning || false,
+    [serviceInfo]
+  )
 
   const schedulerPreconditionSummary = useMemo(
     () => buildCoinjoinRequirementSummary(walletInfo?.data.utxos.utxos || []),
@@ -119,9 +124,12 @@ export default function Jam() {
   }, [reloadServiceInfo, reloadCurrentWalletInfo, t])
 
   useEffect(() => {
-    setCollaborativeOperationRunning(serviceInfo?.coinjoinInProgress || serviceInfo?.makerRunning || false)
+    if (!serviceInfo) return
+
+    setIsWaitingSchedulerStart((current) => (current && serviceInfo.schedule ? false : current))
+    setIsWaitingSchedulerStop((current) => (current && !serviceInfo.schedule ? false : current))
 
-    if (serviceInfo?.schedule && process.env.NODE_ENV === 'development') {
+    if (serviceInfo.schedule && process.env.NODE_ENV === 'development') {
       console.table(serviceInfo.schedule)
     }
   }, [serviceInfo])
@@ -133,6 +141,7 @@ export default function Jam() {
 
     setAlert(null)
     setIsLoading(true)
+    setIsWaitingSchedulerStart(true)
 
     const destinations = [values.dest1, values.dest2, values.dest3]
 
@@ -158,11 +167,15 @@ export default function Jam() {
     return Api.postSchedulerStart({ signal: abortCtrl.signal, walletName: wallet.name, token: wallet.token }, body)
       .then((res) => (res.ok ? true : Api.Helper.throwError(res, t('scheduler.error_starting_schedule_failed'))))
       .then((_) => reloadServiceInfo({ signal: abortCtrl.signal }))
-      .then((_) => setCollaborativeOperationRunning(true))
       .catch((err) => {
+        if (abortCtrl.signal.aborted) return
         setAlert({ variant: 'danger', message: err.message })
+        setIsWaitingSchedulerStart(false)
+      })
+      .finally(() => {
+        if (abortCtrl.signal.aborted) return
+        setIsLoading(false)
       })
-      .finally(() => setIsLoading(false))
   }
 
   const stopSchedule = async () => {
@@ -172,11 +185,11 @@ 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((_) => setCollaborativeOperationRunning(false))
       .then((_) =>
         Promise.all([
           reloadServiceInfo({ signal: abortCtrl.signal }),
@@ -184,9 +197,14 @@ export default function Jam() {
         ])
       )
       .catch((err) => {
+        if (abortCtrl.signal.aborted) return
         setAlert({ variant: 'danger', message: err.message })
+        setIsWaitingSchedulerStop(false)
+      })
+      .finally(() => {
+        if (abortCtrl.signal.aborted) return
+        setIsLoading(false)
       })
-      .finally(() => setIsLoading(false))
   }
 
   return (
@@ -194,7 +212,7 @@ export default function Jam() {
       <PageTitle title={t('scheduler.title')} subtitle={t('scheduler.subtitle')} />
       {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
 
-      {isLoading ? (
+      {isLoading || isWaitingSchedulerStart || isWaitingSchedulerStop ? (
         <rb.Placeholder as="div" animation="wave">
           <rb.Placeholder xs={12} className={styles['input-loader']} />
         </rb.Placeholder>
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..7059299 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",
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)
+  })
+})