Repository: joinmarket-webui/jam
Pull request #534 (2022-10-05 12:09:17)

regtest: speed up scheduled sweep for testing
Was able to reduce the time from >=10min to ~3min for scheduled sweeps.
This is achieved by adapting the "test mode" settings:
- reducing destination addresses from 3 to 1
- reducing the time between transactions
- reducing minimum transaction count per Jar from 2 to 1

```
2022-10-04 07:54:30,991 [DEBUG]  connection was made, starting client.
[...]
2022-10-04 07:57:58,889 [INFO]  Coinjoin completed correctly
```


Example schedule:
```
[
  [0, 0, 1, "INTERNAL", 0.05, 16, 0], 
  [1, 0, 1, "bcrt1qc8s0d5cnhkqyp4fae7epj4up36sjz8gtvp7ru6", 0.03, 16, 0]
]
```

Destination address count can be adapted by changing the `DEST_ADDRESS_COUNT_TEST` constant.

### How to test
Invoke a sweep and verify that ~2 transactions (instead of ~6) are scheduled in quick succession.
If everything works correctly, the operation should take no longer than 5mins.
Pro top: Mine 5 blocks at once with `npm run regtest:mine 5`  while sweeping.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 545640f..08e0aec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+### [0.1.1](https://github.com/joinmarket-webui/jam/compare/v0.1.0...v0.1.1) (2022-10-07)
+
+#### Added
+
+* basic fee settings ([#522](https://github.com/joinmarket-webui/jam/issues/522)) ([54dd396](https://github.com/joinmarket-webui/jam/commit/54dd3969d954112af46d746e7fcc6e78db2ef32f))
+* display fee settings on Send page ([#532](https://github.com/joinmarket-webui/jam/issues/532)) ([26f911a](https://github.com/joinmarket-webui/jam/commit/26f911aea80f472d6f9484a1187e27f7dffe1ec2))
+* **jar:** show sum of selected utxos ([#514](https://github.com/joinmarket-webui/jam/issues/514)) ([85d131c](https://github.com/joinmarket-webui/jam/commit/85d131cf75ba614245de450492bdde1905a6b51c))
+* **send:** show txid on successful direct-send ([#510](https://github.com/joinmarket-webui/jam/issues/510)) ([13496a0](https://github.com/joinmarket-webui/jam/commit/13496a0a066bebb6f8d8b4ef72e25a00f7f347ac))
+
+#### Fixed
+
+* **fees:** mitigate construction of non-forwardable transactions ([#536](https://github.com/joinmarket-webui/jam/issues/536)) ([f2f3944](https://github.com/joinmarket-webui/jam/commit/f2f39444ccd59d4a5a440286a425d1b1fd7c238d))
+* **navbar:** send before earn ([#507](https://github.com/joinmarket-webui/jam/issues/507)) ([c1fb2bc](https://github.com/joinmarket-webui/jam/commit/c1fb2bc68008980087581d4a0d845624f6816b85))
+* **readme:** link to development heading ([0351367](https://github.com/joinmarket-webui/jam/commit/0351367e2971b9717221fa705953d444ff37c587))
+* **settings:** consistent case ([#511](https://github.com/joinmarket-webui/jam/issues/511)) ([e4bd89c](https://github.com/joinmarket-webui/jam/commit/e4bd89c648ea27cd9fb4dd996248eacff0172586))
+* **sweep:** reload wallet info after scheduled sweep ([#530](https://github.com/joinmarket-webui/jam/issues/530)) ([0757280](https://github.com/joinmarket-webui/jam/commit/0757280bf1d1e3b46fdf50c6bcd6268ae379714d))
+* **sweep:** wait for scheduler start/stop ([#529](https://github.com/joinmarket-webui/jam/issues/529)) ([509a15e](https://github.com/joinmarket-webui/jam/commit/509a15e90a5dc58971256eab9cf7d8be1fe796ad))
+
 ## [0.1.0](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.10...v0.1.0) (2022-09-16)
 
 #### Fixed
diff --git a/package-lock.json b/package-lock.json
index 70870ce..82ba0be 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "jam",
-  "version": "0.1.0",
+  "version": "0.1.1",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "jam",
-      "version": "0.1.0",
+      "version": "0.1.1",
       "hasInstallScript": true,
       "license": "MIT",
       "dependencies": {
@@ -42,7 +42,7 @@
         "react-dom": "^17.0.2",
         "react-i18next": "^11.18.6",
         "react-router-bootstrap": "^0.26.2",
-        "react-router-dom": "^6.4.1",
+        "react-router-dom": "^6.4.2",
         "react-scripts": "^5.0.1",
         "typescript": "^4.7.2"
       },
@@ -3833,9 +3833,9 @@
       }
     },
     "node_modules/@remix-run/router": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz",
-      "integrity": "sha512-eBV5rvW4dRFOU1eajN7FmYxjAIVz/mRHgUE9En9mBn6m3mulK3WTR5C3iQhL9MZ14rWAq+xOlEaCkDiW0/heOg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.2.tgz",
+      "integrity": "sha512-GRSOFhJzjGN+d4sKHTMSvNeUPoZiDHWmRnXfzaxrqe7dE/Nzlc8BiMSJdLDESZlndM7jIUrZ/F4yWqVYlI0rwQ==",
       "dev": true,
       "engines": {
         "node": ">=14"
@@ -18613,12 +18613,12 @@
       }
     },
     "node_modules/react-router": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.1.tgz",
-      "integrity": "sha512-OJASKp5AykDWFewgWUim1vlLr7yfD4vO/h+bSgcP/ix8Md+LMHuAjovA74MQfsfhQJGGN1nHRhwS5qQQbbBt3A==",
+      "version": "6.4.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.2.tgz",
+      "integrity": "sha512-Rb0BAX9KHhVzT1OKhMvCDMw776aTYM0DtkxqUBP8dNBom3mPXlfNs76JNGK8wKJ1IZEY1+WGj+cvZxHVk/GiKw==",
       "dev": true,
       "dependencies": {
-        "@remix-run/router": "1.0.1"
+        "@remix-run/router": "1.0.2"
       },
       "engines": {
         "node": ">=14"
@@ -18641,13 +18641,13 @@
       }
     },
     "node_modules/react-router-dom": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.1.tgz",
-      "integrity": "sha512-MY7NJCrGNVJtGp8ODMOBHu20UaIkmwD2V3YsAOUQoCXFk7Ppdwf55RdcGyrSj+ycSL9Uiwrb3gTLYSnzcRoXww==",
+      "version": "6.4.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.2.tgz",
+      "integrity": "sha512-yM1kjoTkpfjgczPrcyWrp+OuQMyB1WleICiiGfstnQYo/S8hPEEnVjr/RdmlH6yKK4Tnj1UGXFSa7uwAtmDoLQ==",
       "dev": true,
       "dependencies": {
-        "@remix-run/router": "1.0.1",
-        "react-router": "6.4.1"
+        "@remix-run/router": "1.0.2",
+        "react-router": "6.4.2"
       },
       "engines": {
         "node": ">=14"
@@ -25478,9 +25478,9 @@
       }
     },
     "@remix-run/router": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.1.tgz",
-      "integrity": "sha512-eBV5rvW4dRFOU1eajN7FmYxjAIVz/mRHgUE9En9mBn6m3mulK3WTR5C3iQhL9MZ14rWAq+xOlEaCkDiW0/heOg==",
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.0.2.tgz",
+      "integrity": "sha512-GRSOFhJzjGN+d4sKHTMSvNeUPoZiDHWmRnXfzaxrqe7dE/Nzlc8BiMSJdLDESZlndM7jIUrZ/F4yWqVYlI0rwQ==",
       "dev": true
     },
     "@restart/hooks": {
@@ -36341,12 +36341,12 @@
       "dev": true
     },
     "react-router": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.1.tgz",
-      "integrity": "sha512-OJASKp5AykDWFewgWUim1vlLr7yfD4vO/h+bSgcP/ix8Md+LMHuAjovA74MQfsfhQJGGN1nHRhwS5qQQbbBt3A==",
+      "version": "6.4.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.4.2.tgz",
+      "integrity": "sha512-Rb0BAX9KHhVzT1OKhMvCDMw776aTYM0DtkxqUBP8dNBom3mPXlfNs76JNGK8wKJ1IZEY1+WGj+cvZxHVk/GiKw==",
       "dev": true,
       "requires": {
-        "@remix-run/router": "1.0.1"
+        "@remix-run/router": "1.0.2"
       }
     },
     "react-router-bootstrap": {
@@ -36359,13 +36359,13 @@
       }
     },
     "react-router-dom": {
-      "version": "6.4.1",
-      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.1.tgz",
-      "integrity": "sha512-MY7NJCrGNVJtGp8ODMOBHu20UaIkmwD2V3YsAOUQoCXFk7Ppdwf55RdcGyrSj+ycSL9Uiwrb3gTLYSnzcRoXww==",
+      "version": "6.4.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.4.2.tgz",
+      "integrity": "sha512-yM1kjoTkpfjgczPrcyWrp+OuQMyB1WleICiiGfstnQYo/S8hPEEnVjr/RdmlH6yKK4Tnj1UGXFSa7uwAtmDoLQ==",
       "dev": true,
       "requires": {
-        "@remix-run/router": "1.0.1",
-        "react-router": "6.4.1"
+        "@remix-run/router": "1.0.2",
+        "react-router": "6.4.2"
       }
     },
     "react-scripts": {
diff --git a/package.json b/package.json
index e76c4f7..a6218d8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "jam",
-  "version": "0.1.0",
+  "version": "0.1.1",
   "private": true,
   "description": "Your sats. Your privacy. Your profit.",
   "repository": "git@github.com:joinmarket-webui/jam.git",
@@ -40,7 +40,7 @@
     "react-dom": "^17.0.2",
     "react-i18next": "^11.18.6",
     "react-router-bootstrap": "^0.26.2",
-    "react-router-dom": "^6.4.1",
+    "react-router-dom": "^6.4.2",
     "react-scripts": "^5.0.1",
     "typescript": "^4.7.2"
   },
diff --git a/src/components/App.tsx b/src/components/App.tsx
index d3d98e5..11a63e8 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1,7 +1,14 @@
 import { useCallback } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import { createBrowserRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from 'react-router-dom'
+import {
+  createBrowserRouter,
+  createRoutesFromElements,
+  Navigate,
+  Route,
+  RouterProvider,
+  Outlet,
+} from 'react-router-dom'
 import { routes } from '../constants/routes'
 import { useSessionConnectionError } from '../context/ServiceInfoContext'
 import { useSettings } from '../context/SettingsContext'
@@ -48,13 +55,9 @@ export default function App() {
           <>
             <Navbar />
             <rb.Container as="main" className="py-4 py-sm-5">
-              {sessionConnectionError && (
-                <rb.Alert variant="danger">
-                  {t('app.alert_no_connection', { connectionError: sessionConnectionError.message })}.
-                </rb.Alert>
-              )}
-
-              <Layout />
+              <Layout>
+                <Outlet />
+              </Layout>
             </rb.Container>
             <Footer />
           </>
@@ -66,11 +69,22 @@ export default function App() {
          * that it stays visible in case the backend becomes unavailable.
          */}
         <Route id="create-wallet" path={routes.createWallet} element={<CreateWallet startWallet={startWallet} />} />
-        {/**
-         * This section defines all routes that are displayed only if the backend is reachable.
-         */}
-        {!sessionConnectionError && (
+
+        {sessionConnectionError ? (
+          <Route
+            id="404"
+            path="*"
+            element={
+              <rb.Alert variant="danger">
+                {t('app.alert_no_connection', { connectionError: sessionConnectionError.message })}.
+              </rb.Alert>
+            }
+          />
+        ) : (
           <>
+            {/**
+             * This section defines all routes that are displayed only if the backend is reachable.
+             */}
             <Route
               id="wallets"
               path={routes.home}
@@ -78,12 +92,16 @@ export default function App() {
             />
             {currentWallet && (
               <>
-                <Route id="wallet" path={routes.wallet} element={<MainWalletView />} />
-                <Route id="jam" path={routes.jam} element={<Jam />} />
-                <Route id="send" path={routes.send} element={<Send />} />
-                <Route id="earn" path={routes.earn} element={<Earn />} />
-                <Route id="receive" path={routes.receive} element={<Receive />} />
-                <Route id="settings" path={routes.settings} element={<Settings stopWallet={stopWallet} />} />
+                <Route id="wallet" path={routes.wallet} element={<MainWalletView wallet={currentWallet} />} />
+                <Route id="jam" path={routes.jam} element={<Jam wallet={currentWallet} />} />
+                <Route id="send" path={routes.send} element={<Send wallet={currentWallet} />} />
+                <Route id="earn" path={routes.earn} element={<Earn wallet={currentWallet} />} />
+                <Route id="receive" path={routes.receive} element={<Receive wallet={currentWallet} />} />
+                <Route
+                  id="settings"
+                  path={routes.settings}
+                  element={<Settings wallet={currentWallet} stopWallet={stopWallet} />}
+                />
               </>
             )}
             <Route id="404" path="*" element={<Navigate to={routes.home} replace={true} />} />
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index d75d283..9a56f3f 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -1,9 +1,9 @@
-import React, { useEffect, useState } from 'react'
+import { useEffect, useState } from 'react'
 import { Formik } from 'formik'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useSettings } from '../context/SettingsContext'
-import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
+import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import Sprite from './Sprite'
 import PageTitle from './PageTitle'
@@ -163,10 +163,9 @@ function CurrentOffer({ offer, nickname }) {
   )
 }
 
-export default function Earn() {
+export default function Earn({ wallet }) {
   const { t } = useTranslation()
   const settings = useSettings()
-  const currentWallet = useCurrentWallet()
   const currentWalletInfo = useCurrentWalletInfo()
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
   const serviceInfo = useServiceInfo()
@@ -187,7 +186,7 @@ export default function Earn() {
     setIsSending(true)
     setIsWaitingMakerStart(true)
 
-    const { name: walletName, token } = currentWallet
+    const { name: walletName, token } = wallet
     const data = {
       ordertype,
       minsize,
@@ -214,7 +213,7 @@ export default function Earn() {
     setIsSending(true)
     setIsWaitingMakerStop(true)
 
-    const { name: walletName, token } = currentWallet
+    const { name: walletName, token } = wallet
 
     // There is no response data to check if maker got stopped:
     // Wait for the websocket or session response!
@@ -249,7 +248,7 @@ export default function Earn() {
       .finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
 
     return () => abortCtrl.abort()
-  }, [currentWallet, isSending, reloadServiceInfo, reloadCurrentWalletInfo])
+  }, [wallet, isSending, reloadServiceInfo, reloadCurrentWalletInfo])
 
   useEffect(() => {
     if (isSending) return
@@ -405,7 +404,7 @@ export default function Earn() {
                         otherFidelityBondExists={fidelityBonds.length > 0}
                         accountBalances={currentWalletInfo.balanceSummary.accountBalances}
                         totalBalance={currentWalletInfo.balanceSummary.totalBalance}
-                        wallet={currentWallet}
+                        wallet={wallet}
                         walletInfo={currentWalletInfo}
                         onDone={() => reloadFidelityBonds({ delay: RELOAD_FIDELITY_BONDS_DELAY_MS })}
                       />
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index 5d4adc3..f2dc653 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -1,11 +1,11 @@
-import React, { useState, useEffect, useCallback, useMemo } from 'react'
+import { useState, useEffect, useCallback, useMemo } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Formik, useFormikContext } from 'formik'
 import * as Api from '../libs/JmWalletApi'
 import { useSettings } from '../context/SettingsContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
-import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
+import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { isDebugFeatureEnabled } from '../constants/debugFeatures'
 import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
 import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
@@ -17,29 +17,34 @@ import ScheduleProgress from './ScheduleProgress'
 
 import styles from './Jam.module.css'
 
-// When running the scheduler with internal destination addresses, the funds
-// will end up on those 3 mixdepths (one UTXO each).
-// Length of this array must be 3 for now.
-const INTERNAL_DEST_ACCOUNTS = [0, 1, 2]
+const DEST_ADDRESS_COUNT_PROD = 3
+const DEST_ADDRESS_COUNT_TEST = 1
 
-const ValuesListener = ({ handler }) => {
+const addressValueKeys = (addressCount) =>
+  Array(addressCount)
+    .fill('')
+    .map((_, index) => `dest${index + 1}`)
+
+const ValuesListener = ({ handler, addressCount }) => {
   const { values } = useFormikContext()
 
   useEffect(() => {
-    if (values.dest1 !== '' && values.dest2 !== '' && values.dest3 !== '') {
+    const allValuesPresent = addressValueKeys(addressCount)
+      .map((key) => values[key])
+      .every((val) => val !== '')
+    if (allValuesPresent) {
       handler()
     }
-  }, [values, handler])
+  }, [values, handler, addressCount])
 
   return null
 }
 
-export default function Jam() {
+export default function Jam({ wallet }) {
   const { t } = useTranslation()
   const settings = useSettings()
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
-  const wallet = useCurrentWallet()
   const walletInfo = useCurrentWalletInfo()
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
 
@@ -59,41 +64,38 @@ export default function Jam() {
   )
 
   // Returns one fresh address for each requested mixdepth.
-  const getNewAddressesForAccounts = useCallback(
-    (mixdepths) => {
-      if (mixdepths.length !== 3) {
-        throw new Error('Can only handle 3 destination addresses for now.')
-      }
+  const getNewAddresses = useCallback(
+    (count, mixdepth = 0) => {
       if (!walletInfo) {
         throw new Error('Wallet info is not available.')
       }
-      return mixdepths.map((mixdepth) => {
-        const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
-          return branch.branch.split('\t')[0] === 'external addresses'
-        })
+      const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
+        return branch.branch.split('\t')[0] === 'external addresses'
+      })
 
-        const newEntry = externalBranch.entries.find((entry) => entry.status === 'new')
+      const newEntries = externalBranch.entries.filter((entry) => entry.status === 'new').slice(0, count)
 
-        if (!newEntry) {
-          throw new Error(`Cannot find a fresh address in mixdepth ${mixdepth}`)
-        }
+      if (newEntries.length !== count) {
+        throw new Error(`Cannot find enough fresh addresses in mixdepth ${mixdepth}`)
+      }
 
-        return newEntry.address
-      })
+      return newEntries.map((it) => it.address)
     },
     [walletInfo]
   )
 
   const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
+  const addressCount = useMemo(
+    () => (useInsecureTestingSettings ? DEST_ADDRESS_COUNT_TEST : DEST_ADDRESS_COUNT_PROD),
+    [useInsecureTestingSettings]
+  )
 
   const initialFormValues = useMemo(() => {
-    const addressCount = 3
-
     let destinationAddresses = Array(addressCount).fill('')
     if (useInsecureTestingSettings) {
       try {
         // prefill with addresses marked as "new"
-        destinationAddresses = getNewAddressesForAccounts(INTERNAL_DEST_ACCOUNTS)
+        destinationAddresses = getNewAddresses(addressCount)
       } catch (e) {
         // on error initialize with empty addresses - form validation will do the rest
         destinationAddresses = Array(addressCount).fill('')
@@ -101,7 +103,7 @@ export default function Jam() {
     }
 
     return destinationAddresses.reduce((obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }), {})
-  }, [useInsecureTestingSettings, getNewAddressesForAccounts])
+  }, [addressCount, useInsecureTestingSettings, getNewAddresses])
 
   useEffect(() => {
     setAlert(null)
@@ -170,23 +172,25 @@ export default function Jam() {
     setAlert(null)
     setIsWaitingSchedulerStart(true)
 
-    const destinations = [values.dest1, values.dest2, values.dest3]
+    const destinations = addressValueKeys(addressCount).map((key) => values[key])
 
     const body = { destination_addresses: destinations }
 
     // Make sure schedule testing is really only used in dev mode.
     if (isDebugFeatureEnabled('insecureScheduleTesting') && useInsecureTestingSettings) {
+      // for a proper description of all parameters see
+      // https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.8/jmclient/jmclient/cli_options.py#L268
       body.tumbler_options = {
-        addrcount: 3,
+        addrcount: addressCount,
         minmakercount: 1,
         makercountrange: [1, 0],
-        mixdepthcount: 3,
-        mintxcount: 2,
-        txcountparams: [1, 1],
-        timelambda: 0.1,
+        mixdepthcount: addressCount,
+        mintxcount: 1,
+        txcountparams: [1, 0],
+        timelambda: 0.025, // 0.025 minutes := 1.5 seconds
         stage1_timelambda_increase: 1.0,
         liquiditywait: 10,
-        waittime: 1.0,
+        waittime: 0.0,
       }
     }
 
@@ -298,15 +302,12 @@ export default function Jam() {
                   return alreadyUsed || duplicateEntry
                 }
 
-                const addressDict = Array(3)
-                  .fill('')
-                  .map((_, index) => {
-                    const key = `dest${index + 1}`
-                    return {
-                      key,
-                      address: values[key],
-                    }
-                  })
+                const addressDict = addressValueKeys(addressCount).map((key) => {
+                  return {
+                    key,
+                    address: values[key],
+                  }
+                })
                 const addresses = addressDict.map((it) => it.address)
 
                 addressDict.forEach((addressEntry) => {
@@ -341,7 +342,7 @@ export default function Jam() {
                 errors,
               }) => (
                 <>
-                  <ValuesListener handler={validateForm} />
+                  <ValuesListener handler={validateForm} addressCount={addressCount} />
                   <rb.Form onSubmit={handleSubmit} noValidate>
                     {!collaborativeOperationRunning && (
                       <>
@@ -357,20 +358,21 @@ export default function Jam() {
                                 setUseInsecureTestingSettings(isToggled)
                                 if (isToggled) {
                                   try {
-                                    const newAddresses = getNewAddressesForAccounts(INTERNAL_DEST_ACCOUNTS)
-                                    setFieldValue('dest1', newAddresses[0], true)
-                                    setFieldValue('dest2', newAddresses[1], true)
-                                    setFieldValue('dest3', newAddresses[2], true)
+                                    const newAddresses = getNewAddresses(DEST_ADDRESS_COUNT_TEST)
+                                    newAddresses.forEach((newAddress, index) => {
+                                      setFieldValue(`dest${index + 1}`, newAddress, true)
+                                    })
                                   } catch (e) {
                                     console.error('Could not get internal addresses.', e)
-                                    setFieldValue('dest1', '', true)
-                                    setFieldValue('dest2', '', true)
-                                    setFieldValue('dest3', '', true)
+
+                                    addressValueKeys(DEST_ADDRESS_COUNT_TEST).forEach((key) => {
+                                      setFieldValue(key, '', true)
+                                    })
                                   }
                                 } else {
-                                  setFieldValue('dest1', '', false)
-                                  setFieldValue('dest2', '', false)
-                                  setFieldValue('dest3', '', false)
+                                  addressValueKeys(DEST_ADDRESS_COUNT_PROD).forEach((key) => {
+                                    setFieldValue(key, '', false)
+                                  })
                                 }
                               }}
                               disabled={isSubmitting}
@@ -380,20 +382,22 @@ export default function Jam() {
                       </>
                     )}
                     {!collaborativeOperationRunning &&
-                      [1, 2, 3].map((i) => {
+                      addressValueKeys(addressCount).map((key, index) => {
                         return (
-                          <rb.Form.Group className="mb-4" key={i} controlId={`dest${i}`}>
-                            <rb.Form.Label>{t('scheduler.label_destination_input', { destination: i })}</rb.Form.Label>
+                          <rb.Form.Group className="mb-4" key={key} controlId={key}>
+                            <rb.Form.Label>
+                              {t('scheduler.label_destination_input', { destination: index + 1 })}
+                            </rb.Form.Label>
                             <rb.Form.Control
-                              name={`dest${i}`}
-                              value={values[`dest${i}`]}
+                              name={key}
+                              value={values[key]}
                               placeholder={t('scheduler.placeholder_destination_input')}
                               onChange={handleChange}
                               onBlur={handleBlur}
-                              isInvalid={touched[`dest${i}`] && !!errors[`dest${i}`]}
+                              isInvalid={touched[key] && !!errors[key]}
                               className={`${styles.input} slashed-zeroes`}
                             />
-                            <rb.Form.Control.Feedback type="invalid">{errors[`dest${i}`]}</rb.Form.Control.Feedback>
+                            <rb.Form.Control.Feedback type="invalid">{errors[key]}</rb.Form.Control.Feedback>
                           </rb.Form.Group>
                         )
                       })}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 8f9ae64..e1830a7 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,6 +1,5 @@
 import { PropsWithChildren } from 'react'
 import * as rb from 'react-bootstrap'
-import { Outlet } from 'react-router-dom'
 
 type LayoutVariant = 'wide' | ''
 
@@ -24,12 +23,10 @@ interface LayoutProps {
   variant?: LayoutVariant
 }
 
-const Layout = ({ variant }: LayoutProps) => {
+const Layout = ({ variant, children }: PropsWithChildren<LayoutProps>) => {
   return (
     <rb.Row className="justify-content-center">
-      <Col variant={variant}>
-        <Outlet />
-      </Col>
+      <Col variant={variant}>{children}</Col>
     </rb.Row>
   )
 }
diff --git a/src/components/MainWalletView.jsx b/src/components/MainWalletView.jsx
index 750c3ee..1b7f903 100644
--- a/src/components/MainWalletView.jsx
+++ b/src/components/MainWalletView.jsx
@@ -3,7 +3,7 @@ import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useNavigate } from 'react-router-dom'
 import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
-import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
+import { useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { walletDisplayName } from '../utils'
 import { routes } from '../constants/routes'
 import Balance from './Balance'
@@ -41,13 +41,12 @@ const WalletHeader = ({ name, balance, unit, showBalance, loading }) => {
   )
 }
 
-export default function MainWalletView() {
+export default function MainWalletView({ wallet }) {
   const { t } = useTranslation()
   const navigate = useNavigate()
 
   const settings = useSettings()
   const settingsDispatch = useSettingsDispatch()
-  const currentWallet = useCurrentWallet()
   const currentWalletInfo = useCurrentWalletInfo()
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
 
@@ -101,7 +100,7 @@ export default function MainWalletView() {
       .finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
 
     return () => abortCtrl.abort()
-  }, [currentWallet, reloadCurrentWalletInfo, t])
+  }, [wallet, reloadCurrentWalletInfo, t])
 
   return (
     <div>
@@ -119,14 +118,14 @@ export default function MainWalletView() {
           initialAccountIndex={selectedAccountIndex}
           utxosByAccount={utxosByAccount}
           walletInfo={currentWalletInfo}
-          wallet={currentWallet}
+          wallet={wallet}
           isShown={isAccountOverlayShown}
           onHide={() => setIsAccountOverlayShown(false)}
         />
       )}
       <rb.Row onClick={() => settingsDispatch({ showBalance: !settings.showBalance })} style={{ cursor: 'pointer' }}>
         <WalletHeader
-          name={currentWallet?.name}
+          name={wallet.name}
           balance={currentWalletInfo?.balanceSummary.totalBalance}
           unit={settings.unit}
           showBalance={settings.showBalance}
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
index a047626..d65b8ba 100644
--- a/src/components/Modal.tsx
+++ b/src/components/Modal.tsx
@@ -1,4 +1,4 @@
-import React, { PropsWithChildren } from 'react'
+import { ReactNode, PropsWithChildren } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import styles from './Modal.module.css'
@@ -6,7 +6,7 @@ import Sprite from './Sprite'
 
 interface ConfirmModalProps {
   isShown: boolean
-  title: React.ReactNode | string
+  title: ReactNode | string
   onCancel: () => void
   onConfirm: () => void
 }
diff --git a/src/components/Receive.jsx b/src/components/Receive.jsx
index 8af0057..f3705eb 100644
--- a/src/components/Receive.jsx
+++ b/src/components/Receive.jsx
@@ -3,7 +3,7 @@ import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { useLocation } from 'react-router-dom'
 import { useSettings } from '../context/SettingsContext'
-import { useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
+import { useCurrentWalletInfo } from '../context/WalletContext'
 import * as Api from '../libs/JmWalletApi'
 
 import { BitcoinQR } from './BitcoinQR'
@@ -14,15 +14,14 @@ import { ShareButton, checkIsWebShareAPISupported } from './ShareButton'
 import styles from './Receive.module.css'
 import { SelectableJar, calculateFillLevel } from './jars/Jar'
 
-export default function Receive() {
+export default function Receive({ wallet }) {
   const { t } = useTranslation()
   const location = useLocation()
   const settings = useSettings()
-  const currentWallet = useCurrentWallet()
   const walletInfo = useCurrentWalletInfo()
   const [validated, setValidated] = useState(false)
   const [alert, setAlert] = useState(null)
-  const [isLoading, setIsLoading] = useState(false)
+  const [isLoading, setIsLoading] = useState(true)
   const [address, setAddress] = useState('')
   const [amount, setAmount] = useState('')
   const [selectedJarIndex, setSelectedJarIndex] = useState(parseInt(location.state?.account, 10) || 0)
@@ -38,7 +37,7 @@ export default function Receive() {
 
   useEffect(() => {
     const abortCtrl = new AbortController()
-    const { name: walletName, token } = currentWallet
+    const { name: walletName, token } = wallet
 
     setAlert(null)
     setIsLoading(true)
@@ -54,7 +53,7 @@ export default function Receive() {
       .finally(() => !abortCtrl.signal.aborted && setIsLoading(false))
 
     return () => abortCtrl.abort()
-  }, [selectedJarIndex, currentWallet, addressCount, t])
+  }, [wallet, selectedJarIndex, addressCount, t])
 
   const onSubmit = (e) => {
     e.preventDefault()
diff --git a/src/components/ScheduleProgress.jsx b/src/components/ScheduleProgress.jsx
index 03709f3..1f74502 100644
--- a/src/components/ScheduleProgress.jsx
+++ b/src/components/ScheduleProgress.jsx
@@ -34,7 +34,10 @@ const scheduleToSteps = (schedule) => {
   // Calculate total wait time. Ignores the last element since there's no reason in waiting after the last element completed.
   // This is a lower bound estimate for the time the whole scheduler run will take since it doesn't take into account the time
   // it takes to find and talk to makers and wait for tx confirmations on the timechain.
-  const totalWaitTime = schedule.slice(0, schedule.length - 1).reduce((acc, tx) => acc + tx[4] * 60, 0)
+  const totalWaitTime = Math.max(
+    1,
+    schedule.slice(0, schedule.length - 1).reduce((acc, tx) => acc + tx[4] * 60, 0)
+  )
   // Distribute the tx's on the progress bar relative to the waittime after the previous tx completes.
   const txs = schedule.map((tx, i) => {
     const minWidth = 8
@@ -43,7 +46,7 @@ const scheduleToSteps = (schedule) => {
       return { width: minWidth }
     }
 
-    const proportionalWidth = ((schedule[i - 1][4] * 60) / totalWaitTime) * 100
+    const proportionalWidth = ((Math.max(1, schedule[i - 1][4]) * 60) / totalWaitTime) * 100
 
     return { width: Math.max(proportionalWidth, minWidth) }
   })
@@ -86,7 +89,7 @@ const ScheduleProgress = ({ schedule }) => {
     <div className="d-flex flex-column gap-3">
       <div>
         <p className="mb-1">
-          {Math.ceil(steps.totalWaitTime / 60 / 60) === 1 ? (
+          {Math.ceil(steps.totalWaitTime / 60 / 60) <= 1 ? (
             <Trans i18nKey="scheduler.progress_tldr_seconds">
               Currently running a schedule of <strong>{{ length: steps.txs.length }}</strong> transactions over
               <strong>{{ seconds: Math.ceil(steps.totalWaitTime) }}</strong> seconds.
@@ -106,7 +109,7 @@ const ScheduleProgress = ({ schedule }) => {
           {stepsJsx}
         </div>
       </div>
-      <div className={[styles['text'], 'text-secondary'].join(' ')}>
+      <div className={[styles.text, 'text-secondary'].join(' ')}>
         <div>
           {steps.completedTxs < steps.txs.length ? (
             <>
diff --git a/src/components/Seedphrase.jsx b/src/components/Seedphrase.tsx
similarity index 83%
rename from src/components/Seedphrase.jsx
rename to src/components/Seedphrase.tsx
index ec3e22f..cfd9c39 100644
--- a/src/components/Seedphrase.jsx
+++ b/src/components/Seedphrase.tsx
@@ -1,7 +1,12 @@
-import React from 'react'
 import styles from './Seedphrase.module.css'
 
-export default function Seedphrase({ seedphrase, isBlurred = true, centered = false }) {
+interface SeedphraseProps {
+  seedphrase: string
+  isBlurred?: boolean
+  centered?: boolean
+}
+
+export default function Seedphrase({ seedphrase, isBlurred = true, centered = false }: SeedphraseProps) {
   return (
     <div
       className={`${styles.container} slashed-zeroes d-flex flex-wrap ${
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index eba1d7e..8cc8f55 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -9,20 +9,22 @@ import Sprite from './Sprite'
 import Balance from './Balance'
 import JarSelectorModal from './JarSelectorModal'
 
-import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
+import { useReloadCurrentWalletInfo, useCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useLoadConfigValue } from '../context/ServiceConfigContext'
 import { useSettings } from '../context/SettingsContext'
+import { estimateMaxCollaboratorFee, toTxFeeValueUnit, useLoadFeeConfigValues } from '../hooks/Fees'
 import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
 
 import * as Api from '../libs/JmWalletApi'
-import { SATS, formatBtc, formatSats } from '../utils'
+import { SATS, formatBtc, formatSats, isValidNumber } from '../utils'
 import { routes } from '../constants/routes'
-import styles from './Send.module.css'
 import { ConfirmModal } from './Modal'
 import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
 import { jarInitial, jarName } from './jars/Jar'
 
+import styles from './Send.module.css'
+
 const IS_COINJOIN_DEFAULT_VAL = true
 // initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
 const MINIMUM_MAKERS_DEFAULT_VAL = 4
@@ -52,17 +54,17 @@ const isValidAddress = (candidate) => {
 
 const isValidAccount = (candidate) => {
   const parsed = parseInt(candidate, 10)
-  return !isNaN(parsed) && parsed >= 0
+  return isValidNumber(parsed) && parsed >= 0
 }
 
 const isValidAmount = (candidate, isSweep) => {
   const parsed = parseInt(candidate, 10)
-  return !isNaN(parsed) && (isSweep ? parsed === 0 : parsed > 0)
+  return isValidNumber(parsed) && (isSweep ? parsed === 0 : parsed > 0)
 }
 
 const isValidNumCollaborators = (candidate, minNumCollaborators) => {
   const parsed = parseInt(candidate, 10)
-  return !isNaN(parsed) && parsed >= minNumCollaborators && parsed <= 99
+  return isValidNumber(parsed) && parsed >= minNumCollaborators && parsed <= 99
 }
 
 const CollaboratorsSelector = ({ numCollaborators, setNumCollaborators, minNumCollaborators, disabled = false }) => {
@@ -214,15 +216,183 @@ function SweepAccordionToggle({ eventKey }) {
   )
 }
 
-export default function Send() {
+function PaymentConfirmModal({
+  isShown,
+  title,
+  onCancel,
+  onConfirm,
+  data: { sourceJarId, destination, amount, isSweep, isCoinjoin, numCollaborators, feeConfigValues },
+}) {
+  const { t } = useTranslation()
+  const settings = useSettings()
+
+  const estimatedMaxCollaboratorFee = useMemo(() => {
+    if (!amount || !isCoinjoin || !numCollaborators || !feeConfigValues) return null
+    if (!isValidNumber(feeConfigValues.max_cj_fee_abs) || !isValidNumber(feeConfigValues.max_cj_fee_rel)) return null
+    return estimateMaxCollaboratorFee({
+      amount,
+      collaborators: numCollaborators,
+      maxFeeAbs: feeConfigValues.max_cj_fee_abs,
+      maxFeeRel: feeConfigValues.max_cj_fee_rel,
+    })
+  }, [amount, isCoinjoin, numCollaborators, feeConfigValues])
+
+  const miningFeeText = useMemo(() => {
+    if (!feeConfigValues) return null
+    if (!isValidNumber(feeConfigValues.tx_fees) || !isValidNumber(feeConfigValues.tx_fees_factor)) return null
+
+    const unit = toTxFeeValueUnit(feeConfigValues.tx_fees)
+    if (!unit) {
+      return null
+    } else if (unit === 'blocks') {
+      return t('send.confirm_send_modal.text_miner_fee_in_targeted_blocks', { count: feeConfigValues.tx_fees })
+    } else {
+      const feeTargetInSatsPerVByte = feeConfigValues.tx_fees / 1_000
+      if (feeConfigValues.tx_fees_factor === 0) {
+        return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_exact', {
+          value: feeTargetInSatsPerVByte.toLocaleString(undefined, {
+            maximumFractionDigits: Math.log10(1_000),
+          }),
+        })
+      }
+
+      const minFeeSatsPerVByte = Math.max(1, feeTargetInSatsPerVByte * (1 - feeConfigValues.tx_fees_factor))
+      const maxFeeSatsPerVByte = feeTargetInSatsPerVByte * (1 + feeConfigValues.tx_fees_factor)
+
+      return t('send.confirm_send_modal.text_miner_fee_in_satspervbyte_randomized', {
+        min: minFeeSatsPerVByte.toLocaleString(undefined, {
+          maximumFractionDigits: 1,
+        }),
+        max: maxFeeSatsPerVByte.toLocaleString(undefined, {
+          maximumFractionDigits: 1,
+        }),
+      })
+    }
+  }, [t, feeConfigValues])
+
+  return (
+    <ConfirmModal isShown={isShown} title={title} onCancel={onCancel} onConfirm={onConfirm}>
+      <rb.Container className="mt-2">
+        <rb.Row className="mt-2 mb-3">
+          <rb.Col xs={12} className="text-center">
+            {isCoinjoin ? (
+              <strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
+            ) : (
+              <strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
+            )}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start">
+            {t('send.confirm_send_modal.text_source_jar', { jarId: sourceJarId })}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_recipient')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start text-break slashed-zeroes">
+            {destination}
+          </rb.Col>
+        </rb.Row>
+        <rb.Row>
+          <rb.Col xs={4} md={3} className="text-end">
+            <strong>{t('send.confirm_send_modal.label_amount')}</strong>
+          </rb.Col>
+          <rb.Col xs={8} md={9} className="text-start">
+            {isSweep ? (
+              <>
+                <Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
+                  Sweep
+                  <Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
+                </Trans>
+                <rb.OverlayTrigger
+                  placement="right"
+                  overlay={
+                    <rb.Popover>
+                      <rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
+                    </rb.Popover>
+                  }
+                >
+                  <div className="d-inline-flex align-items-center">
+                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
+                  </div>
+                </rb.OverlayTrigger>
+              </>
+            ) : (
+              <Balance valueString={amount} convertToUnit={settings.unit} showBalance={true} />
+            )}
+          </rb.Col>
+        </rb.Row>
+
+        {miningFeeText && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_miner_fee')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {miningFeeText}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {isCoinjoin && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="text-start">
+              {numCollaborators}
+            </rb.Col>
+          </rb.Row>
+        )}
+        {estimatedMaxCollaboratorFee && (
+          <rb.Row>
+            <rb.Col xs={4} md={3} className="text-end">
+              <strong>{t('send.confirm_send_modal.label_estimated_max_collaborator_fee')}</strong>
+            </rb.Col>
+            <rb.Col xs={8} md={9} className="d-inline-flex align-items-center text-start">
+              <div>
+                &le;
+                <Balance
+                  valueString={`${estimatedMaxCollaboratorFee}`}
+                  convertToUnit={settings.unit}
+                  showBalance={true}
+                />
+                <rb.OverlayTrigger
+                  placement="right"
+                  overlay={
+                    <rb.Popover>
+                      <rb.Popover.Body>
+                        {t('send.confirm_send_modal.text_estimated_max_collaborator_fee_info_popover')}
+                      </rb.Popover.Body>
+                    </rb.Popover>
+                  }
+                >
+                  <div className="d-inline-flex align-items-center">
+                    <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
+                  </div>
+                </rb.OverlayTrigger>
+              </div>
+            </rb.Col>
+          </rb.Row>
+        )}
+      </rb.Container>
+    </ConfirmModal>
+  )
+}
+
+export default function Send({ wallet }) {
   const { t } = useTranslation()
-  const wallet = useCurrentWallet()
   const walletInfo = useCurrentWalletInfo()
 
   const reloadCurrentWalletInfo = useReloadCurrentWalletInfo()
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
   const loadConfigValue = useLoadConfigValue()
+  const loadFeeConfigValues = useLoadFeeConfigValues()
   const settings = useSettings()
   const location = useLocation()
 
@@ -238,6 +408,8 @@ export default function Send() {
   const [destinationJar, setDestinationJar] = useState(null)
   const [destinationIsReusedAddress, setDestinationIsReusedAddress] = useState(false)
 
+  const [feeConfigValues, setFeeConfigValues] = useState(null)
+
   const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
   const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)
 
@@ -409,12 +581,24 @@ export default function Send() {
         !abortCtrl.signal.aborted && setAlert({ variant: 'danger', message: err.message })
       })
 
-    Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig]).finally(
+    const loadFeeValues = loadFeeConfigValues(abortCtrl.signal)
+      .then((data) => {
+        if (abortCtrl.signal.aborted) return
+        setFeeConfigValues(data)
+      })
+      .catch((e) => {
+        if (abortCtrl.signal.aborted) return
+        // As fee config is not essential, don't raise an error on purpose.
+        // Fee settings cannot be displayed, but making a payment is still possible.
+        setFeeConfigValues(null)
+      })
+
+    Promise.all([loadingServiceInfo, loadingWalletInfoAndUtxos, loadingMinimumMakerConfig, loadFeeValues]).finally(
       () => !abortCtrl.signal.aborted && setIsInitializing(false)
     )
 
     return () => abortCtrl.abort()
-  }, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, t])
+  }, [isOperationDisabled, wallet, reloadCurrentWalletInfo, reloadServiceInfo, loadConfigValue, loadFeeConfigValues, t])
 
   useEffect(() => {
     if (destination !== null && walletInfo?.addressSummary[destination]) {
@@ -582,7 +766,7 @@ export default function Send() {
   }
 
   const amountFieldValue = () => {
-    if (amount === null || Number.isNaN(amount)) return ''
+    if (amount === null || !isValidNumber(amount)) return ''
 
     if (isSweep) {
       if (!accountBalanceOrNull) return ''
@@ -950,89 +1134,23 @@ export default function Send() {
           {t('send.confirm_abort_modal.text_body')}
         </ConfirmModal>
 
-        <ConfirmModal
+        <PaymentConfirmModal
           isShown={showConfirmSendModal}
           title={t('send.confirm_send_modal.title')}
           onCancel={() => setShowConfirmSendModal(false)}
           onConfirm={() => {
             submitButtonRef.current?.click()
           }}
-        >
-          <rb.Container className="mt-2">
-            <rb.Row className="mt-2 mb-3">
-              <rb.Col xs={12} className="text-center">
-                {isCoinjoin ? (
-                  <strong className="text-success">{t('send.confirm_send_modal.text_collaborative_tx_enabled')}</strong>
-                ) : (
-                  <strong className="text-danger">{t('send.confirm_send_modal.text_collaborative_tx_disabled')}</strong>
-                )}
-              </rb.Col>
-            </rb.Row>
-            <rb.Row>
-              <rb.Col xs={3} className="text-end">
-                <strong>{t('send.confirm_send_modal.label_source_jar')}</strong>
-              </rb.Col>
-              <rb.Col xs={9} className="text-start">
-                {t('send.confirm_send_modal.text_source_jar', { jarId: jarInitial(account) })}
-              </rb.Col>
-            </rb.Row>
-            <rb.Row>
-              <rb.Col xs={3} className="text-end">
-                <strong>{t('send.confirm_send_modal.label_recipient')}</strong>
-              </rb.Col>
-              <rb.Col xs={9} className="text-start text-break slashed-zeroes">
-                {destination}
-              </rb.Col>
-            </rb.Row>
-            <rb.Row>
-              <rb.Col xs={3} className="text-end">
-                <strong>{t('send.confirm_send_modal.label_amount')}</strong>
-              </rb.Col>
-              <rb.Col xs={9} className="text-start">
-                {isSweep ? (
-                  <div className="d-flex justify-content-start align-items-center">
-                    <Trans i18nKey="send.confirm_send_modal.text_sweep_balance">
-                      Sweep
-                      <Balance
-                        valueString={amountFieldValue().toString()}
-                        convertToUnit={settings.unit}
-                        showBalance={true}
-                      />
-                    </Trans>
-                    <rb.OverlayTrigger
-                      placement="right"
-                      overlay={
-                        <rb.Popover>
-                          <rb.Popover.Body>{t('send.confirm_send_modal.text_sweep_info_popover')}</rb.Popover.Body>
-                        </rb.Popover>
-                      }
-                    >
-                      <div className="d-inline-flex align-items-center">
-                        <Sprite className={styles.infoIcon} symbol="info" width="13" height="13" />
-                      </div>
-                    </rb.OverlayTrigger>
-                  </div>
-                ) : (
-                  <Balance
-                    valueString={amountFieldValue().toString()}
-                    convertToUnit={settings.unit}
-                    showBalance={true}
-                  />
-                )}
-              </rb.Col>
-            </rb.Row>
-            {isCoinjoin && (
-              <rb.Row>
-                <rb.Col xs={3} className="text-end">
-                  <strong>{t('send.confirm_send_modal.label_num_collaborators')}</strong>
-                </rb.Col>
-                <rb.Col xs={9} className="text-start">
-                  {numCollaborators}
-                </rb.Col>
-              </rb.Row>
-            )}
-          </rb.Container>
-        </ConfirmModal>
+          data={{
+            sourceJarId: jarInitial(account),
+            destination,
+            amount: amountFieldValue().toString(),
+            isSweep,
+            isCoinjoin,
+            numCollaborators,
+            feeConfigValues,
+          }}
+        />
       </div>
     </>
   )
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx
index 5f00718..4d8fae1 100644
--- a/src/components/Settings.jsx
+++ b/src/components/Settings.jsx
@@ -1,96 +1,29 @@
-import React, { useEffect, useState, useCallback } from 'react'
+import { useEffect, useState, useCallback } from 'react'
 import { Link, useNavigate } from 'react-router-dom'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import Sprite from './Sprite'
-import Seedphrase from './Seedphrase'
-import ToggleSwitch from './ToggleSwitch'
 import Alert from './Alert'
 import { LogOverlay } from './LogOverlay'
 import { ConfirmModal } from './Modal'
 import { useSettings, useSettingsDispatch } from '../context/SettingsContext'
-import { useCurrentWallet } from '../context/WalletContext'
 import { useServiceInfo } from '../context/ServiceInfoContext'
-import { SATS, BTC, walletDisplayName } from '../utils'
+import { SATS, BTC } from '../utils'
 import * as Api from '../libs/JmWalletApi'
 import { fetchFeatures } from '../libs/JamApi'
 import { routes } from '../constants/routes'
 import languages from '../i18n/languages'
 import styles from './Settings.module.css'
+import SeedModal from './settings/SeedModal'
 import FeeConfigModal from './settings/FeeConfigModal'
 
-function SeedModal({ show = false, onHide }) {
-  const { t } = useTranslation()
-  const currentWallet = useCurrentWallet()
-  const [revealSeed, setRevealSeed] = useState(false)
-  const [seedError, setSeedError] = useState(false)
-  const [seed, setSeed] = useState(null)
-  const [isLoading, setIsLoading] = useState(true)
-
-  useEffect(() => {
-    const loadSeed = async () => {
-      setIsLoading(true)
-      try {
-        const { name: walletName, token } = currentWallet
-        const res = await Api.getWalletSeed({ walletName, token })
-        const { seedphrase } = await (res.ok ? res.json() : Api.Helper.throwError(res))
-
-        setIsLoading(false)
-        setSeed(seedphrase)
-      } catch (e) {
-        setIsLoading(false)
-        setSeedError(true)
-      }
-    }
-
-    if (show) {
-      loadSeed()
-    }
-  }, [show, currentWallet])
-
-  return (
-    <rb.Modal size="lg" show={show} onHide={onHide} keyboard={false} centered={true} animation={true}>
-      <rb.Modal.Header closeButton>
-        <rb.Modal.Title>{walletDisplayName(currentWallet.name)}</rb.Modal.Title>
-      </rb.Modal.Header>
-      <rb.Modal.Body>
-        <>
-          {isLoading && (
-            <div className="d-flex justify-content-center align-items-center">
-              <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
-            </div>
-          )}
-          {seedError && (
-            <div className="text-danger" style={{ marginLeft: '1rem' }}>
-              {t('settings.error_loading_seed_failed')}
-            </div>
-          )}
-          {seed && (
-            <>
-              <div className="mb-4">{t('settings.seed_modal_info_text')}</div>
-              <rb.Row className="justify-content-center align-items-center">
-                <rb.Col xs={12} md={10} className="mb-4">
-                  <Seedphrase seedphrase={seed} centered={true} isBlurred={!revealSeed} />
-                </rb.Col>
-              </rb.Row>
-              <div className="d-flex justify-content-center align-items-center">
-                <div className="mb-2">
-                  <ToggleSwitch
-                    label={t('settings.reveal_seed')}
-                    toggledOn={revealSeed}
-                    onToggle={(isToggled) => setRevealSeed(isToggled)}
-                  />
-                </div>
-              </div>
-            </>
-          )}
-        </>
-      </rb.Modal.Body>
-    </rb.Modal>
-  )
-}
+export default function Settings({ wallet, stopWallet }) {
+  const { t, i18n } = useTranslation()
+  const navigate = useNavigate()
+  const settings = useSettings()
+  const settingsDispatch = useSettingsDispatch()
+  const serviceInfo = useServiceInfo()
 
-export default function Settings({ stopWallet }) {
   const [showingSeed, setShowingSeed] = useState(false)
   const [showingFeeConfig, setShowingFeeConfig] = useState(false)
   const [lockingWallet, setLockingWallet] = useState(false)
@@ -99,14 +32,6 @@ export default function Settings({ stopWallet }) {
   const [showingLogs, setShowingLogs] = useState(false)
   const [alert, setAlert] = useState(null)
 
-  const { t } = useTranslation()
-  const settings = useSettings()
-  const settingsDispatch = useSettingsDispatch()
-  const { i18n } = useTranslation()
-  const currentWallet = useCurrentWallet()
-  const navigate = useNavigate()
-  const serviceInfo = useServiceInfo()
-
   const setTheme = (theme) => {
     if (window.JM.THEMES.includes(theme)) {
       document.documentElement.setAttribute(window.JM.THEME_ROOT_ATTR, theme)
@@ -127,7 +52,7 @@ export default function Settings({ stopWallet }) {
       setLockingWallet(true)
 
       try {
-        const { name: walletName, token } = currentWallet
+        const { name: walletName, token } = wallet
         const res = await Api.getWalletLock({ walletName, token })
 
         setLockingWallet(false)
@@ -142,12 +67,12 @@ export default function Settings({ stopWallet }) {
         setAlert({ variant: 'danger', dismissible: false, message: e.message })
       }
     },
-    [currentWallet, stopWallet, navigate, serviceInfo]
+    [wallet, stopWallet, navigate, serviceInfo]
   )
 
   useEffect(() => {
     const abortCtrl = new AbortController()
-    fetchFeatures({ token: currentWallet?.token, signal: abortCtrl.signal })
+    fetchFeatures({ token: wallet.token, signal: abortCtrl.signal })
       .then((res) => (res.ok ? res.json() : Api.Helper.throwError(res)))
       .then((data) => data && data.features)
       .then((features) => {
@@ -162,7 +87,7 @@ export default function Settings({ stopWallet }) {
     return () => {
       abortCtrl.abort()
     }
-  }, [currentWallet])
+  }, [wallet])
 
   return (
     <div className={styles.settings}>
@@ -237,27 +162,23 @@ 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)} />}
-            </>
-          )}
+          <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 && (
+          {showLogsEnabled && (
             <>
               <rb.Button variant="outline-dark" className={styles['settings-btn']} onClick={() => setShowingLogs(true)}>
                 <Sprite symbol="console" width="24" height="24" />
                 {t('settings.show_logs')}
               </rb.Button>
-              <LogOverlay currentWallet={currentWallet} show={showingLogs} onHide={() => setShowingLogs(false)} />
+              <LogOverlay currentWallet={wallet} show={showingLogs} onHide={() => setShowingLogs(false)} />
             </>
           )}
         </div>
@@ -268,7 +189,7 @@ export default function Settings({ stopWallet }) {
             <Sprite symbol="mnemonic" width="24" height="24" />
             {showingSeed ? t('settings.hide_seed') : t('settings.show_seed')}
           </rb.Button>
-          {showingSeed && <SeedModal show={showingSeed} onHide={() => setShowingSeed(false)} />}
+          {showingSeed && <SeedModal wallet={wallet} show={showingSeed} onHide={() => setShowingSeed(false)} />}
 
           <rb.Button
             variant="outline-dark"
diff --git a/src/components/settings/FeeConfigModal.tsx b/src/components/settings/FeeConfigModal.tsx
index b2cc027..c921d85 100644
--- a/src/components/settings/FeeConfigModal.tsx
+++ b/src/components/settings/FeeConfigModal.tsx
@@ -1,53 +1,51 @@
 import { forwardRef, useRef, useCallback, useEffect, useState } from 'react'
 import * as rb from 'react-bootstrap'
-import { useTranslation } from 'react-i18next'
+import { Trans, useTranslation } from 'react-i18next'
 import { Formik, FormikErrors } from 'formik'
 import classNames from 'classnames'
-import { useRefreshConfigValues, useUpdateConfigValues } from '../../context/ServiceConfigContext'
-import { factorToPercentage, percentageToFactor } from '../../utils'
+import { FEE_CONFIG_KEYS, TxFeeValueUnit, toTxFeeValueUnit, FeeValues, useLoadFeeConfigValues } from '../../hooks/Fees'
+import { useUpdateConfigValues } from '../../context/ServiceConfigContext'
+import { isValidNumber, factorToPercentage, percentageToFactor } from '../../utils'
 import Sprite from '../Sprite'
-import styles from './FeeConfigModal.module.css'
 import SegmentedTabs from '../SegmentedTabs'
+import styles from './FeeConfigModal.module.css'
 
 type SatsPerKiloVByte = number
 
 const TX_FEES_BLOCKS_MIN = 1
 const TX_FEES_BLOCKS_MAX = 1_000
-const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 1_000 // 1 sat/vbyte
+
+/**
+ * When the fee target is low, JM sometimes constructs transactions, which are
+ * declined from being relayed. In order to mitigate such situations, the
+ * minimum fee target (when provided in sats/vbyte) must be higher than
+ * 1 sats/vbyte, till the problem is addressed. Once resolved, this
+ * can be lowered to 1 sats/vbyte again.
+ * See https://github.com/JoinMarket-Org/joinmarket-clientserver/issues/1360#issuecomment-1262295463
+ * Last checked on 2022-10-06.
+ */
+const TX_FEES_SATSPERKILOVBYTE_MIN: SatsPerKiloVByte = 3_000 // 3 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%
+/**
+ * For the same reasons as stated above (comment for `TX_FEES_SATSPERKILOVBYTE_MIN`),
+ * the maximum randomization factor must not be too high.
+ * Settling on 50% as a reasonable compromise until this problem is addressed.
+ * Once resolved, this can be set to 100% again.
+ */
+const TX_FEES_FACTOR_MAX = 0.5 // 50%
 const CJ_FEE_ABS_MIN = 1
 const CJ_FEE_ABS_MAX = 1_000_000 // 0.01 BTC - no enforcement by JM - this should be a "sane" max value
 const CJ_FEE_REL_MIN = 0.000001 // 0.0001%
 const CJ_FEE_REL_MAX = 0.05 // 5% - no enforcement by JM - this should be a "sane" max value
 
-const isValidNumber = (val: number | undefined) => typeof val === 'number' && !isNaN(val)
-
-const FEE_KEYS = {
-  tx_fees: { section: 'POLICY', field: 'tx_fees' },
-  tx_fees_factor: { section: 'POLICY', field: 'tx_fees_factor' },
-  max_cj_fee_abs: { section: 'POLICY', field: 'max_cj_fee_abs' },
-  max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' },
-}
-
 interface FeeConfigModalProps {
   show: boolean
   onHide: () => void
 }
 
-type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte'
-
-interface FeeValues {
-  tx_fees?: number
-  tx_fees_factor?: number
-  max_cj_fee_abs?: number
-  max_cj_fee_rel?: number
-}
-
 interface FeeConfigFormProps {
   initialValues: FeeValues
   validate: (values: FeeValues, txFeesUnit: TxFeeValueUnit) => FormikErrors<FeeValues>
@@ -58,9 +56,7 @@ const FeeConfigForm = forwardRef(
   ({ onSubmit, validate, initialValues }: FeeConfigFormProps, ref: React.Ref<HTMLFormElement>) => {
     const { t, i18n } = useTranslation()
 
-    const [txFeesUnit, setTxFeesUnit] = useState<TxFeeValueUnit>(
-      initialValues.tx_fees && initialValues.tx_fees > 1_000 ? 'sats/kilo-vbyte' : 'blocks'
-    )
+    const [txFeesUnit, setTxFeesUnit] = useState<TxFeeValueUnit>(toTxFeeValueUnit(initialValues.tx_fees) || 'blocks')
 
     return (
       <Formik
@@ -312,8 +308,8 @@ const FeeConfigForm = forwardRef(
 
 export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
   const { t } = useTranslation()
-  const refreshConfigValues = useRefreshConfigValues()
   const updateConfigValues = useUpdateConfigValues()
+  const loadFeeConfigValues = useLoadFeeConfigValues()
   const [isLoading, setIsLoading] = useState(true)
   const [isSubmitting, setIsSubmitting] = useState(false)
   const [loadError, setLoadError] = useState(false)
@@ -322,47 +318,31 @@ export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
   const formRef = useRef<HTMLFormElement>(null)
 
   useEffect(() => {
-    const loadFeeValues = async (signal: AbortSignal) => {
-      setLoadError(false)
+    setLoadError(false)
+
+    const abortCtrl = new AbortController()
+    if (show) {
       setIsLoading(true)
 
-      try {
-        const serviceConfig = await refreshConfigValues({
-          signal,
-          keys: Object.values(FEE_KEYS),
+      loadFeeConfigValues(abortCtrl.signal)
+        .then((val) => {
+          if (abortCtrl.signal.aborted) return
+          setIsLoading(false)
+          setFeeConfigValues(val)
         })
-
-        setIsLoading(false)
-
-        const policy = serviceConfig['POLICY'] || {}
-
-        const feeValues: FeeValues = {
-          tx_fees: parseInt(policy.tx_fees || '', 10) || undefined,
-          tx_fees_factor: parseFloat(policy.tx_fees_factor || `${TX_FEES_FACTOR_DEFAULT_VAL}`),
-          max_cj_fee_abs: parseInt(policy.max_cj_fee_abs || '', 10) || undefined,
-          max_cj_fee_rel: parseFloat(policy.max_cj_fee_rel || '') || undefined,
-        }
-        setFeeConfigValues(feeValues)
-      } catch (e) {
-        if (!signal.aborted) {
+        .catch((e) => {
+          if (abortCtrl.signal.aborted) return
           setIsLoading(false)
           setLoadError(true)
-        }
-      }
-    }
-
-    const abortCtrl = new AbortController()
-    if (show) {
-      loadFeeValues(abortCtrl.signal)
+        })
     } else {
-      setLoadError(false)
       setSaveErrorMessage(undefined)
     }
 
     return () => {
       abortCtrl.abort()
     }
-  }, [show, refreshConfigValues])
+  }, [show, loadFeeConfigValues])
 
   const submit = async (feeValues: FeeValues, txFeesUnit: TxFeeValueUnit) => {
     const allValuesPresent = Object.values(feeValues).every((it) => it !== undefined)
@@ -380,19 +360,19 @@ export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
 
     const updates = [
       {
-        key: FEE_KEYS.tx_fees,
+        key: FEE_CONFIG_KEYS.tx_fees,
         value: String(adjustedTxFees),
       },
       {
-        key: FEE_KEYS.tx_fees_factor,
+        key: FEE_CONFIG_KEYS.tx_fees_factor,
         value: String(feeValues.tx_fees_factor),
       },
       {
-        key: FEE_KEYS.max_cj_fee_abs,
+        key: FEE_CONFIG_KEYS.max_cj_fee_abs,
         value: String(feeValues.max_cj_fee_abs),
       },
       {
-        key: FEE_KEYS.max_cj_fee_rel,
+        key: FEE_CONFIG_KEYS.max_cj_fee_rel,
         value: String(feeValues.max_cj_fee_rel),
       },
     ]
@@ -502,7 +482,20 @@ export default function FeeConfigModal({ show, onHide }: FeeConfigModalProps) {
       </rb.Modal.Header>
       <rb.Modal.Body>
         <>
-          <div className="mb-4 small">{t('settings.fees.description')}</div>
+          <div className="mb-4 small">
+            <Trans i18nKey="settings.fees.description">
+              Fee description. See
+              <a
+                href="https://jamdocs.org/market/fees/"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="link-dark"
+              >
+                this link
+              </a>
+              for more information.
+            </Trans>
+          </div>
           {loadError && (
             <rb.Alert variant="danger" className="w-100">
               {t('settings.fees.error_loading_fee_config_failed')}
diff --git a/src/components/settings/SeedModal.tsx b/src/components/settings/SeedModal.tsx
new file mode 100644
index 0000000..a40867d
--- /dev/null
+++ b/src/components/settings/SeedModal.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useState } from 'react'
+import * as rb from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import Seedphrase from '../Seedphrase'
+import ToggleSwitch from '../ToggleSwitch'
+import { CurrentWallet } from '../../context/WalletContext'
+import { walletDisplayName } from '../../utils'
+import * as Api from '../../libs/JmWalletApi'
+
+interface SeedModalProps {
+  wallet: CurrentWallet
+  show: boolean
+  onHide: () => void
+}
+
+export default function SeedModal({ wallet, show, onHide }: SeedModalProps) {
+  const { t } = useTranslation()
+
+  const [revealSeed, setRevealSeed] = useState(false)
+  const [seedError, setSeedError] = useState(false)
+  const [seed, setSeed] = useState(null)
+  const [isLoading, setIsLoading] = useState(true)
+
+  useEffect(() => {
+    const loadSeed = async () => {
+      setIsLoading(true)
+      try {
+        const { name: walletName, token } = wallet
+        const res = await Api.getWalletSeed({ walletName, token })
+        const { seedphrase } = await (res.ok ? res.json() : Api.Helper.throwError(res))
+
+        setIsLoading(false)
+        setSeed(seedphrase)
+      } catch (e) {
+        setIsLoading(false)
+        setSeedError(true)
+      }
+    }
+
+    if (show) {
+      loadSeed()
+    }
+  }, [show, wallet])
+
+  return (
+    <rb.Modal size="lg" show={show} onHide={onHide} keyboard={false} centered={true} animation={true}>
+      <rb.Modal.Header closeButton>
+        <rb.Modal.Title>{walletDisplayName(wallet.name)}</rb.Modal.Title>
+      </rb.Modal.Header>
+      <rb.Modal.Body>
+        <>
+          {isLoading && (
+            <div className="d-flex justify-content-center align-items-center">
+              <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" />
+            </div>
+          )}
+          {seedError && (
+            <div className="text-danger" style={{ marginLeft: '1rem' }}>
+              {t('settings.error_loading_seed_failed')}
+            </div>
+          )}
+          {seed && (
+            <>
+              <div className="mb-4">{t('settings.seed_modal_info_text')}</div>
+              <rb.Row className="justify-content-center align-items-center">
+                <rb.Col xs={12} md={10} className="mb-4">
+                  <Seedphrase seedphrase={seed} centered={true} isBlurred={!revealSeed} />
+                </rb.Col>
+              </rb.Row>
+              <div className="d-flex justify-content-center align-items-center">
+                <div className="mb-2">
+                  <ToggleSwitch
+                    label={t('settings.reveal_seed')}
+                    toggledOn={revealSeed}
+                    onToggle={(isToggled) => setRevealSeed(isToggled)}
+                  />
+                </div>
+              </div>
+            </>
+          )}
+        </>
+      </rb.Modal.Body>
+    </rb.Modal>
+  )
+}
diff --git a/src/hooks/Fees.ts b/src/hooks/Fees.ts
new file mode 100644
index 0000000..e772b0c
--- /dev/null
+++ b/src/hooks/Fees.ts
@@ -0,0 +1,71 @@
+import { useCallback } from 'react'
+import { useRefreshConfigValues } from '../context/ServiceConfigContext'
+import { AmountSats } from '../libs/JmWalletApi'
+import { isValidNumber } from '../utils'
+
+export type TxFeeValueUnit = 'blocks' | 'sats/kilo-vbyte'
+
+export const toTxFeeValueUnit = (val?: number): TxFeeValueUnit | undefined => {
+  if (val === undefined || !Number.isInteger(val) || val < 1) return undefined
+  return val <= 1_000 ? 'blocks' : 'sats/kilo-vbyte'
+}
+
+export const FEE_CONFIG_KEYS = {
+  tx_fees: { section: 'POLICY', field: 'tx_fees' },
+  tx_fees_factor: { section: 'POLICY', field: 'tx_fees_factor' },
+  max_cj_fee_abs: { section: 'POLICY', field: 'max_cj_fee_abs' },
+  max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' },
+}
+
+export interface FeeValues {
+  tx_fees?: number
+  tx_fees_factor?: number
+  max_cj_fee_abs?: number
+  max_cj_fee_rel?: number
+}
+
+export const useLoadFeeConfigValues = () => {
+  const refreshConfigValues = useRefreshConfigValues()
+
+  return useCallback(
+    async (signal?: AbortSignal) => {
+      const serviceConfig = await refreshConfigValues({
+        signal,
+        keys: Object.values(FEE_CONFIG_KEYS),
+      })
+
+      const policy = serviceConfig['POLICY'] || {}
+
+      const parsedTxFees = parseInt(policy.tx_fees || '', 10)
+      const parsedTxFeesFactor = parseFloat(policy.tx_fees_factor || '')
+      const parsedMaxFeeAbs = parseInt(policy.max_cj_fee_abs || '', 10)
+      const parsedMaxFeeRel = parseFloat(policy.max_cj_fee_rel || '')
+
+      const feeValues: FeeValues = {
+        tx_fees: isValidNumber(parsedTxFees) ? parsedTxFees : undefined,
+        tx_fees_factor: isValidNumber(parsedTxFeesFactor) ? parsedTxFeesFactor : undefined,
+        max_cj_fee_abs: isValidNumber(parsedMaxFeeAbs) ? parsedMaxFeeAbs : undefined,
+        max_cj_fee_rel: isValidNumber(parsedMaxFeeRel) ? parsedMaxFeeRel : undefined,
+      }
+      return feeValues
+    },
+    [refreshConfigValues]
+  )
+}
+
+interface EstimatMaxCollaboratorFeeProps {
+  amount: AmountSats
+  collaborators: number
+  maxFeeAbs: AmountSats
+  maxFeeRel: number // e.g. 0.001 for 0.1%
+}
+
+export const estimateMaxCollaboratorFee = ({
+  amount,
+  collaborators,
+  maxFeeAbs,
+  maxFeeRel,
+}: EstimatMaxCollaboratorFeeProps) => {
+  const maxFeePerCollaborator = Math.max(Math.ceil(amount * maxFeeRel), maxFeeAbs)
+  return collaborators > 0 ? Math.min(maxFeePerCollaborator * collaborators, amount) : 0
+}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 87e2786..f919881 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -172,7 +172,7 @@
     "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.",
+      "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. For more information, <1>see the documentation on fees</1>.",
       "title_general_fee_settings": "Mining fees",
       "radio_tx_fees_blocks": "Block target",
       "radio_tx_fees_satspervbyte": "sats/vByte",
@@ -248,7 +248,14 @@
       "text_source_jar": "Jar {{ jarId }}",
       "label_num_collaborators": "Collaborators",
       "text_collaborative_tx_enabled": "Payment with privacy improvement",
-      "text_collaborative_tx_disabled": "Payment without privacy improvement"
+      "text_collaborative_tx_disabled": "Payment without privacy improvement",
+      "label_estimated_max_collaborator_fee": "Collaborator fee",
+      "text_estimated_max_collaborator_fee_info_popover": "This value represents a maximum upper limit. The actual fee is likely to be much lower.",
+      "label_miner_fee": "Mining fee",
+      "text_miner_fee_in_satspervbyte_exact": "{{ value }} sats/vByte",
+      "text_miner_fee_in_satspervbyte_randomized": "{{ min }} - {{ max }} sats/vByte",
+      "text_miner_fee_in_targeted_blocks_one": "High priority estimate (next block)",
+      "text_miner_fee_in_targeted_blocks_other": "Using estimate for inclusion in next {{ count }} blocks"
     },
     "confirm_abort_modal": {
       "title": "Abort payment",
diff --git a/src/utils.ts b/src/utils.ts
index 62fa6d6..e952394 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -60,3 +60,5 @@ export const factorToPercentage = (val: number, precision = 6) => {
   // but: ✓ Number((0.000027 * 100).toFixed(6)) = 0.0027
   return Number((val * 100).toFixed(precision))
 }
+
+export const isValidNumber = (val: number | undefined) => typeof val === 'number' && !isNaN(val)


Test patch:
diff --git a/docker/regtest/common.sh b/docker/regtest/common.sh
index 965a36e..4054585 100644
--- a/docker/regtest/common.sh
+++ b/docker/regtest/common.sh
@@ -189,7 +189,7 @@ fetch_available_wallets() {
 }
 
 # --------------------------
-# Verify no open session
+# Fetch Session Information
 # --------------------------
 ## API: GET /api/v1/session
 ## 
@@ -202,10 +202,20 @@ fetch_available_wallets() {
 ##    "wallet_name": "None"
 ## }
 ##
+fetch_session() {
+    local base_url; base_url=${1:-}
+    local session_result; session_result="$(curl "$base_url/api/v1/session" --silent --show-error --insecure | jq ".")"
+
+    echo "$session_result"
+}
+
+# --------------------------
+# Verify no open session
+# --------------------------
 verify_no_open_session_or_throw() {
     local base_url; base_url=${1:-}
 
-    if session_result=$(curl "$base_url/api/v1/session" --silent --show-error --insecure | jq "."); then
+    if session_result=$(fetch_session "$base_url"); then
       msg_success "Successfully established connection to jmwalletd"
     else rc=$?
       die "Could not connect to joinmarket. Please make sure jmwalletd is running inside container."
@@ -216,3 +226,12 @@ verify_no_open_session_or_throw() {
 
     return 0
 }
+
+is_maker_running() {
+  local base_url; base_url=${1:-}
+
+  local session_result; session_result=$(fetch_session "$base_url")
+  local maker_running; maker_running=$(jq -r '.maker_running' <<< "$session_result")
+
+  echo "$maker_running"
+}
diff --git a/docker/regtest/docker-compose-common.yml b/docker/regtest/docker-compose-common.yml
new file mode 100644
index 0000000..fa77901
--- /dev/null
+++ b/docker/regtest/docker-compose-common.yml
@@ -0,0 +1,45 @@
+version: "3"
+
+services:
+
+  joinmarket_native:
+    build:
+      context: ./dockerfile-deps/joinmarket/latest
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    environment:
+      READY_FILE: /root/.regtest-initializer/btc_fully_synched
+      ENSURE_WALLET: "true"
+      REMOVE_LOCK_FILES: "true"
+      jm_blockchain_source: regtest
+      jm_network: testnet
+      jm_rpc_host: bitcoind
+      jm_rpc_port: 43782
+      jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file}
+      jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4
+      jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5
+      jm_maker_timeout_sec: 30 # easier testing of maker timeouts on regtest (and "Stall Monitor" retries); default is 60
+    expose:
+      - 62601 # obwatch
+      - 28183 # jmwalletd api
+      - 28283 # jmwalletd websocket
+
+  joinmarket_jam_standalone:
+    build:
+      context: ./dockerfile-deps/joinmarket/webui-standalone
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    environment:
+      READY_FILE: /root/.regtest-initializer/btc_fully_synched
+      ENSURE_WALLET: "true"
+      REMOVE_LOCK_FILES: "true"
+      jm_blockchain_source: regtest
+      jm_network: testnet
+      jm_rpc_host: bitcoind
+      jm_rpc_port: 43782
+      jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file}
+      jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4
+      jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5
+    expose:
+      - 80    # nginx
+      - 28183 # jmwalletd api
diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml
index f56c111..fc8e45f 100644
--- a/docker/regtest/docker-compose.yml
+++ b/docker/regtest/docker-compose.yml
@@ -4,30 +4,13 @@ services:
 
   joinmarket:
     container_name: jm_regtest_joinmarket
-    build:
-      context: ./dockerfile-deps/joinmarket/latest
-      dockerfile: Dockerfile
-    restart: unless-stopped
+    extends:
+      file: docker-compose-common.yml
+      service: joinmarket_native
     environment:
-      READY_FILE: /root/.regtest-initializer/btc_fully_synched
-      ENSURE_WALLET: "true"
-      REMOVE_LOCK_FILES: "true"
       jm_rpc_wallet_file: jm_primary
-      jm_minimum_makers: 1
-      jm_tx_broadcast: self
-      jm_blockchain_source: regtest
-      jm_network: testnet
-      jm_rpc_host: bitcoind
-      jm_rpc_port: 43782
       jm_rpc_user: joinmarket
       jm_rpc_password: joinmarket
-      jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file}
-      jm_socks5: "false" # will _not_ connect to local irc over tor
-    expose:
-      - 62601 # obwatch
-      - 27183 # joinmarketd
-      - 28183 # jmwalletd api
-      - 28283 # jmwalletd websocket
     ports:
       - "62601:62601"
       - "28183:28183"
@@ -42,39 +25,18 @@ services:
 
   joinmarket2:
     container_name: jm_regtest_joinmarket2
-    build:
-      context: ./dockerfile-deps/joinmarket/webui-standalone
-      dockerfile: Dockerfile
-    restart: unless-stopped
+    extends:
+      file: docker-compose-common.yml
+      service: joinmarket_jam_standalone
     environment:
-      READY_FILE: /root/.regtest-initializer/btc_fully_synched
-      ENSURE_WALLET: "true"
-      REMOVE_LOCK_FILES: "true"
       APP_USER: joinmarket
       APP_PASSWORD: joinmarket
       jm_rpc_wallet_file: jm_secondary
-      jm_minimum_makers: 1
-      jm_tx_broadcast: self
-      jm_blockchain_source: regtest
-      jm_network: testnet
-      jm_rpc_host: bitcoind
-      jm_rpc_port: 43782
       jm_rpc_user: joinmarket2
       jm_rpc_password: joinmarket2
-      jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file}
-      jm_socks5: "false" # will _not_ connect to local irc over tor
-      jm_taker_utxo_retries: 1 # easier testing of commitment failures on regtest; default is 3
-      maker_timeout_sec: 10 # easier testing of maker timeouts on regtest (and "Stall Monitor" retries); default is 60
-    expose:
-      - 80    # nginx
-      - 62601 # obwatch
-      - 27183 # joinmarketd
-      - 28183 # jmwalletd api
-      - 28283 # jmwalletd websocket
     ports:
-      - "29183:28183"
-      - "29283:28283"
       - "29080:80"
+      - "29183:28183" # exposed for "init setup" routine
     volumes:
       - "joinmarket2_datadir:/root/.joinmarket"
       - "initializer_datadir:/root/.regtest-initializer"
@@ -83,6 +45,26 @@ services:
       - bitcoind_regtest_initializer
       - irc
 
+  joinmarket3:
+    container_name: jm_regtest_joinmarket3
+    extends:
+      file: docker-compose-common.yml
+      service: joinmarket_jam_standalone
+    environment:
+      jm_rpc_wallet_file: jm_tertiary
+      jm_rpc_user: joinmarket3
+      jm_rpc_password: joinmarket3
+    ports:
+      - "30080:80"
+      - "30183:28183" # exposed for "init setup" routine
+    volumes:
+      - "joinmarket3_datadir:/root/.joinmarket"
+      - "initializer_datadir:/root/.regtest-initializer"
+    depends_on:
+      - bitcoind
+      - bitcoind_regtest_initializer
+      - irc
+
   joinmarket_directory_node:
     container_name: jm_regtest_joinmarket_directory_node
     build:
@@ -140,6 +122,8 @@ services:
         rpcauth=joinmarket:260b4c5b1fbd09d75a4aabf90226282f$$76e170af088d43a588992cdd5e7bae2242b03c33aa672cccfd1fb75f9281299e
         # rpcauth (user=joinmarket2; password=joinmarket2)
         rpcauth=joinmarket2:521bf9f4468529d49c0a41f9c9f8fdbf$$63ae94a73d2aa45e7ee756945d9b1e469f9873ce026b815d676a748f777e0b8d
+        # rpcauth (user=joinmarket3; password=joinmarket3)
+        rpcauth=joinmarket3:85d4beaa74540c3b08f4fef50d74a59e$$3033c779ea4bfd02a1f3403bc4d012f3e6d19b355f74c5e8de1d3439979d5e4b
     expose:
       - 43782 # RPC
       - 39388 # P2P
@@ -183,5 +167,6 @@ volumes:
   initializer_datadir:
   joinmarket_datadir:
   joinmarket2_datadir:
+  joinmarket3_datadir:
   joinmarket_directory_node_datadir:
   irc_datadir:
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
index f2fcf9d..b091a1f 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
@@ -166,13 +166,13 @@ max_sweep_fee_change = 0.8
 # market maker for a transaction. Both the limits given in
 # max_cj_fee_abs and max_cj_fee_rel must be exceeded in order
 # to not consider a certain offer.
-max_cj_fee_abs = 300000 # 0.003 btc
+max_cj_fee_abs = 10000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200 million satoshi) and
 # max_cj_fee_rel = 0.001 (0.1%), the maximum fee allowed would
 # be 0.002 btc (200 thousand satoshi)
-max_cj_fee_rel = 0.0003 # 0.03%
+max_cj_fee_rel = 0.0003
 
 # The range of confirmations passed to the `listunspent` bitcoind RPC call
 # 1st value is the inclusive minimum, defaults to one confirmation
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
index d08aef1..ca4183c 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
@@ -178,7 +178,7 @@ max_sweep_fee_change = 0.8
 # to not consider a certain offer.
 
 # taken from jam production image
-max_cj_fee_abs = 300000
+max_cj_fee_abs = 10000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200 million satoshi) and
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh b/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh
index 7734fb2..b3e0a4b 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/jam-entrypoint.sh
@@ -53,6 +53,12 @@ done < <(env -0)
 # ensure a wallet name is present
 jmenv['rpc_wallet_file']=${jmenv['rpc_wallet_file']:-'jm_webui_default'}
 
+# Make sure `max_cj_fee_abs` and `max_cj_fee_rel` are set
+# `max_cj_fee_abs` between 5000 - 10000 sats if not provided
+jmenv['max_cj_fee_abs']=${jmenv['max_cj_fee_abs']:-"$(shuf -i 5000-10000 -n1)"}
+# `max_cj_fee_rel` between 0.01 - 0.03% if not provided
+jmenv['max_cj_fee_rel']=${jmenv['max_cj_fee_rel']:-"0.000$((RANDOM%3+1))"}
+
 # adapt 'blockchain_source' if missing and we're in regtest mode
 if [ "${jmenv['network']}" = "regtest" ] && [ "${jmenv['blockchain_source']}" = "" ]; then
     jmenv['blockchain_source']='regtest'
diff --git a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
index d08aef1..ca4183c 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
@@ -178,7 +178,7 @@ max_sweep_fee_change = 0.8
 # to not consider a certain offer.
 
 # taken from jam production image
-max_cj_fee_abs = 300000
+max_cj_fee_abs = 10000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
 # e.g. if your coinjoin amount is 2 btc (200 million satoshi) and
diff --git a/docker/regtest/fund-wallet.sh b/docker/regtest/fund-wallet.sh
index 6521b53..4803cca 100755
--- a/docker/regtest/fund-wallet.sh
+++ b/docker/regtest/fund-wallet.sh
@@ -103,11 +103,13 @@ parse_params() {
   [ "$blocks" -ge 1 ] || die "Invalid parameter: 'blocks' must be a positve integer"
 
   [ "$container" == "jm_regtest_joinmarket" ] || 
-  [ "$container" == "jm_regtest_joinmarket2" ] || 
+  [ "$container" == "jm_regtest_joinmarket2" ] ||
+  [ "$container" == "jm_regtest_joinmarket3" ] || 
   die "Invalid parameter: 'container' must be a known container name"
 
   [ "$container" == "jm_regtest_joinmarket" ] && base_url='https://localhost:28183'
   [ "$container" == "jm_regtest_joinmarket2" ] && base_url='https://localhost:29183'
+  [ "$container" == "jm_regtest_joinmarket3" ] && base_url='https://localhost:30183'
 
   return 0
 }
diff --git a/docker/regtest/init-setup.sh b/docker/regtest/init-setup.sh
index 5841b93..6666027 100755
--- a/docker/regtest/init-setup.sh
+++ b/docker/regtest/init-setup.sh
@@ -2,12 +2,12 @@
 
 ###
 #
-# This script helps initializing the joinmarket docker containers.
+# This script helps initializing the JoinMarket docker containers.
 # Its main goal is to make CoinJoin transactions possible in the regtest environment.
 #
 # It has two responsibilities:
-# - funding wallets in both containers with some coins
-# - starting the maker service in the secondary container
+# - funding wallets in all containers with some coins
+# - starting the maker service in the secondary and tertiary container
 #
 ###
 
@@ -15,64 +15,63 @@ set -Eeuo pipefail
 
 script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
 
-# fund wallet in primary joinmarket container
+# fund wallet in primary JoinMarket container
 . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --unmatured --blocks 3
 
-# fund wallet in secondary joinmarket container
-# this will get more coins than the primary one in order to successfully run the tumbler.py script
+# fund wallet in secondary and tertiary JoinMarket container.
+# these will get more coins than the primary one in order to have enough liquidity
+# to run the scheduler (scheduled sweep) successfully multiple times.
 . "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50
+. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket3 --unmatured --blocks 50
 
 # make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
 . "$script_dir/mine-block.sh" 106 &>/dev/null
 
+start_maker() {
+    local base_url; base_url=${1:-}
+    local wallet_name; wallet_name=${2:-}
+    local wallet_password; wallet_password=${3:-}
+    
+    # Check if maker service is not yet running
+    verify_no_open_session_or_throw "$base_url"
 
-base_url='https://localhost:29183'
-wallet_name='Satoshi.jmdat'
-wallet_password='test'
-
-msg "Attempt to start maker service for wallet $wallet_name in secondary container.."
-
-# --------------------------
-# Check if maker service is not yet running
-# --------------------------
-if session_result=$(curl "$base_url/api/v1/session" --silent --show-error --insecure | jq "."); then
-  msg_success "Successfully established connection to jmwalletd"
-else rc=$?
-  die "Could not connect to joinmarket. Please make sure jmwalletd is running inside container."
-fi
-
-maker_running=$(jq -r '.maker_running' <<< "$session_result")
-
-if [ "$maker_running" != false ]; then
-  msg_success "Maker is already running"
-else
-    # --------------------------
-    # Unlock wallet
-    # --------------------------
-    unlock_result=$(unlock_wallet "$base_url" "$wallet_name" "$wallet_password")
-
-    auth_token=$(jq -r '.token' <<< "$unlock_result")
-    auth_header="Authorization: Bearer $auth_token"
-
-    # --------------------------
-    # Start maker
-    # --------------------------
-    ## API: POST /api/v1/wallet/$wallet_name/maker/start
-    ## 
-    ## Response: 
-    ## 200 OK
-    ## {}
-    msg "Starting maker service for wallet $wallet_name.."  
-    start_maker_request_payload="{\"txfee\":\"0\",\"cjfee_a\":\"250\",\"cjfee_r\":\"0.0003\",\"ordertype\":\"sw0absoffer\",\"minsize\":\"1\"}"
-
-    start_maker_result=$(curl "$base_url/api/v1/wallet/$wallet_name/maker/start" --silent --show-error --insecure -H "$auth_header" --data "$start_maker_request_payload" | jq ".")
+    local maker_running; maker_running=$(is_maker_running "$base_url")
 
-    if [ "$start_maker_result" != "{}" ]; then
-        msg_warn "There has been a problem starting the maker service: $start_maker_result"
+    if [ "$maker_running" != false ]; then
+      msg_success "Maker is already running"
     else
-        msg_success "Successfully started maker for wallet $wallet_name in secondary container."
+        # Unlock wallet
+        local unlock_result; unlock_result=$(unlock_wallet "$base_url" "$wallet_name" "$wallet_password")
+
+        local auth_token; auth_token=$(jq -r '.token' <<< "$unlock_result")
+        local auth_header; auth_header="Authorization: Bearer $auth_token"
+
+        # --------------------------
+        # Start maker
+        # --------------------------
+        ## API: POST /api/v1/wallet/$wallet_name/maker/start
+        ## 
+        ## Response: 
+        ## 200 OK
+        ## {}
+        msg "Starting maker service for wallet $wallet_name.."  
+        local start_maker_request_payload; start_maker_request_payload="{\"txfee\":\"0\",\"cjfee_a\":\"250\",\"cjfee_r\":\"0.0003\",\"ordertype\":\"sw0absoffer\",\"minsize\":\"1\"}"
+
+        local start_maker_result; start_maker_result=$(curl "$base_url/api/v1/wallet/$wallet_name/maker/start" --silent --show-error --insecure -H "$auth_header" --data "$start_maker_request_payload" | jq ".")
+
+        if [ "$start_maker_result" != "{}" ]; then
+            msg_warn "There has been a problem starting the maker service: $start_maker_result"
+        else
+            msg_success "Successfully started maker for wallet $wallet_name."
+        fi
+        
+        # do not lock the wallet as this will terminate the maker service
+        msg_warn "Wallet $wallet_name remains unlocked!"
     fi
-    
-    # do not lock the wallet as this will terminate the maker service
-    msg_warn "Wallet $wallet_name in secondary container remains unlocked!"
-fi
+}
+
+msg "Attempt to start maker service for wallet $wallet_name in secondary container.."
+start_maker "https://localhost:29183" "Satoshi.jmdat" "test"
+
+msg "Attempt to start maker service for wallet $wallet_name in tertiary container.."
+start_maker "https://localhost:30183" "Satoshi.jmdat" "test"
diff --git a/docker/regtest/readme.md b/docker/regtest/readme.md
index 471aa7c..82e53da 100644
--- a/docker/regtest/readme.md
+++ b/docker/regtest/readme.md
@@ -4,7 +4,7 @@ This setup will help you set up a regtest environment quickly.
 It starts multiple JoinMarket containers, hence not only API calls but also actual CoinJoin transactions can be tested.
 Communication between these containers is done via Tor (if internet connection is available) and IRC (locally running container).
 
-Both containers will have a wallet named `Satoshi.jmdat` with password `test`.
+All containers will have a wallet named `Satoshi.jmdat` with password `test`.
 The second container has basic auth enabled (username `joinmarket` and password `joinmarket`).
 
 ## Common flow
@@ -15,7 +15,7 @@ npm run regtest:rebuild
 # start the regtest environment
 npm run regtest:up
 
-# fund wallets and start maker in secondary container
+# fund wallets and start maker in secondary and tertiary container
 npm run regtest:init
 
 # mine blocks in regtest periodically
@@ -42,7 +42,7 @@ Start the regtest environment with:
 ```sh
 npm run regtest:up
 
-# (optional) fund wallets and start maker in secondary container
+# (optional) fund wallets and start maker in secondary and tertiary containers
 npm run regtest:init
 ```
 
@@ -80,9 +80,10 @@ Keep in mind: Building from `master` is not always reliable. This tradeoff is ma
 
 The second JoinMarket container is based on `joinmarket-webui/jam-dev-standalone:master` which exposes an UI on port `29080`
 (username `joinmarket` and pass `joinmarket` for Basic Authentication).
+The third container is a copy of the second one exposed on port `30080` without authentication.
 This is useful if you want to perform regression tests.
 
-The third JoinMarket container acts as [Directory Node](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/onion-message-channels.md#directory) and exists solely to enable communication between peers.
+One additional JoinMarket container acts as [Directory Node](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/onion-message-channels.md#directory) and exists solely to enable communication between peers.
 
 ### Build
 ```sh
@@ -118,13 +119,13 @@ Some helper scripts are included to make recurring tasks and interaction with th
 
 ### `init-setup.sh`
 
-This script helps in providing both JoinMarket containers a wallet with spendable coins and starting the Maker Service in the secondary container.
+This script helps in providing JoinMarket containers a wallet with spendable coins and starting the Maker Service in the secondary and tertiary containers.
 Its main goal is to make CoinJoin transactions possible in the regtest environment.
 It should be run immediately after the Docker setup is successfully started so you can start developing right away.
 A wallet named `Satoshi.jmdat` with password `test` will be created if it does not exist.
 
 ```sh
-# fund wallets and start maker service in secondary container
+# fund wallets and start maker service in secondary and tertiary containers
 [user@home regtest]$ ./init-setup.sh
 ```
 
@@ -132,8 +133,11 @@ A wallet named `Satoshi.jmdat` with password `test` will be created if it does n
 [...]
 Attempt to start maker for wallet 'Satoshi.jmdat' in secondary container ..
 [...]
-Starting maker service for wallet 'Satoshi.jmdat'
-Successfully started maker for wallet 'Satoshi.jmdat' in secondary container.
+Successfully started maker for wallet 'Satoshi.jmdat'.
+[...]
+Attempt to start maker for wallet 'Satoshi.jmdat' in tertiary container ..
+[...]
+Successfully started maker for wallet 'Satoshi.jmdat'.
 [...]
 ```
 
diff --git a/src/components/Settings.test.jsx b/src/components/Settings.test.jsx
index 892fbdd..20aaa4f 100644
--- a/src/components/Settings.test.jsx
+++ b/src/components/Settings.test.jsx
@@ -1,21 +1,30 @@
 import { BrowserRouter } from 'react-router-dom'
-
 import { render, screen } from '../testUtils'
 import { act } from 'react-dom/test-utils'
 
 import Settings from './Settings'
 
+const dummyWalletName = 'dummy.jmdat'
+const dummyToken = 'dummyToken'
+
 describe('<Settings />', () => {
-  const setup = () => {
+  const setup = ({ wallet }) => {
     render(
       <BrowserRouter>
-        <Settings />
+        <Settings wallet={wallet} />
       </BrowserRouter>
     )
   }
 
-  it('should render settings without errors', () => {
-    act(setup)
+  it('should render settings without errors', async () => {
+    await act(async () =>
+      setup({
+        wallet: {
+          name: dummyWalletName,
+          token: dummyToken,
+        },
+      })
+    )
 
     expect(screen.getByText('settings.title')).toBeVisible()
     expect(screen.queryByText(/settings.(show|hide)_balance/)).toBeVisible()
diff --git a/src/hooks/Fees.test.ts b/src/hooks/Fees.test.ts
new file mode 100644
index 0000000..8ebafd9
--- /dev/null
+++ b/src/hooks/Fees.test.ts
@@ -0,0 +1,69 @@
+import { toTxFeeValueUnit, estimateMaxCollaboratorFee } from './Fees'
+
+describe('toTxFeeValueUnit', () => {
+  it('should return correct unit', () => {
+    expect(toTxFeeValueUnit(undefined)).toBe(undefined)
+    expect(toTxFeeValueUnit(NaN)).toBe(undefined)
+    expect(toTxFeeValueUnit(-1)).toBe(undefined)
+    expect(toTxFeeValueUnit(0)).toBe(undefined)
+    expect(toTxFeeValueUnit(1.1)).toBe(undefined)
+    expect(toTxFeeValueUnit(1_001.1)).toBe(undefined)
+
+    expect(toTxFeeValueUnit(1)).toBe('blocks')
+    expect(toTxFeeValueUnit(999)).toBe('blocks')
+    expect(toTxFeeValueUnit(1_000)).toBe('blocks')
+
+    expect(toTxFeeValueUnit(1_001)).toBe('sats/kilo-vbyte')
+    expect(toTxFeeValueUnit(1001 + Math.round(Math.random() * 1_000_000))).toBe('sats/kilo-vbyte')
+    expect(toTxFeeValueUnit(Number.MAX_SAFE_INTEGER)).toBe('sats/kilo-vbyte')
+  })
+})
+
+describe('estimateMaxCollaboratorFee', () => {
+  it('should return estimate based on max fee', () => {
+    const data = {
+      amount: 100_000_000, // 1 btc
+      maxFeeAbs: 21_000,
+      maxFeeRel: 0.0002, // 0.02% = (20k sats)
+    }
+
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: -1 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 0 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 1 })).toBe(21_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 2 })).toBe(42_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 3 })).toBe(63_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 10 })).toBe(210_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 21_000 })).toBe(data.amount)
+  })
+
+  it('should return estimate based on rel fee', () => {
+    const data = {
+      amount: 100_000_000, // 1 btc
+      maxFeeAbs: 9_001,
+      maxFeeRel: 0.0001, // 0.01% = (10k sats)
+    }
+
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: -1 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 0 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 1 })).toBe(10_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 2 })).toBe(20_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 3 })).toBe(30_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 10 })).toBe(100_000)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 21_000 })).toBe(data.amount)
+  })
+
+  it('should return amount if max. fees are higher than amount', () => {
+    const data = {
+      amount: 21, // 21 sats
+      maxFeeAbs: 22,
+      maxFeeRel: 1, // 100%
+    }
+
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: -1 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 0 })).toBe(0)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 1 })).toBe(data.amount)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 2 })).toBe(data.amount)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 10 })).toBe(data.amount)
+    expect(estimateMaxCollaboratorFee({ ...data, collaborators: 21_000 })).toBe(data.amount)
+  })
+})