Repository: joinmarket-webui/joinmarket-webui
Pull request #259 (2022-05-12 12:01:19)

dev: directory node setup for regtest
Sets up a local directory node in regtest environment.

### Rationale
The test system should mirror the live system in most regards as closely as possible.

#### Benefits
:+1: Increased Reliability; IRC communication was cumbersome
:+1: Lower latency; Increased execution time
:+1: Closer to the mainnet system; communication is done over tor

#### Drawbacks
:-1: Longer build time (own image with `--with-local-tor` must be built)
:-1: Needs a prepare script before setup can boot up
:-1: Communication is done over tor; might not be good/fast for everyone
:-1: Internet connection needed

### Approach
Problem: We do not know the hostname of the directory node before the setup is started.

Solution: Before the setup can be started it is prepared via script `prepare-setup.sh`.
This in turn calls `generate-onion-address.sh` to generate onion key pair + hostname which will be written to an `.env.generated` file. The env file is used in `docker-compose.yml` to inject the hostname to all JM instances.

The start script `start-dn.py` in the docker image is taken from repository [`JoinMarket-Org/custom-scripts` ](https://github.com/JoinMarket-Org/custom-scripts/blob/c8c3bbb3aeaa86e70b08e7d655e5ed676ff31b8f/start-dn.py).

#### Caveats
- a `.env.example` file exists on purpose to be able to execute commands before the `.env.generated` has been created. This is necessary if `regtest:up` has never been invoked, but other actions should still work (e.g. `regtest:rebuild`, `regtest:clear`, etc.)

- "Cheating" a little, when creating onion keys: Generating the hostname is done with a fork of [`Kexkey/vanitytorgen`](https://github.com/Kexkey/vanitytorgen). This was the easiest and quickest method available. Although purposed to generate vanity addresses, by choosing a random starting char it generates unique hostnames without prefix at every start-up (refrained from using "jam" as prefix). This can be improved – feedback welcome.

### Testing
- Rebuild the setup with `npm run regtest:rebuild`
- Start with `npm run regtest:up`
- Follow outputs of log file with `npm run regtest:logs:directory_node`
- Do whatever in the primary or secondary instance and observe if your actions are successful

###### Current additional Changes (that will likely be reverted and applied in a different PR, as they do not fit the primary objective)
Moved to https://github.com/joinmarket-webui/joinmarket-webui/pull/268
- ~~Set the same fees used as defaults in the jam "production" image~~
- ~~Because the communication is more reliable with directory nodes, let's also:~~
  - ~~Revert the `taker_utxo_retries` from 10 to 3 (default value)~~
  - ~~Revert the `taker_utxo_age` from 1 to 5 (default value)~~

It has been tested with https://github.com/joinmarket-webui/joinmarket-webui/pull/268 and it seems to work fine.
"Schedule said to process "6 transactions over 23 seconds" and then took ~7 minutes with block time at 10 seconds."
Tests included: Running scheduler (with "insecure test options"), a single taker operation, and a successful maker operation.



theborakompanioni: > 


Thanks for your comments so far and for testing :+1:
> Seems to work fine for me! white_check_mark Haven't looked at the code yet, though.

:muscle: 

> But I tested:
> 
>     * Scheduler
> 
>     * Earn
> 
>     * Send with 1 collaborator

:+1:
Did you notice an increase in speed and reliability?

> How will the messaging server be chosen (onion vs. IRC) -- randomly?

I have not looked into the code and could not find explanations regarding this in https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/onion-message-channels.md – it seems that onion messaging takes precedence, but this is in uninformed guess.

dnlggr: Just tried it again. Worked reliably. So while I can't know if it is more reliable or faster it feels good. 👌 

Patch:
diff --git a/package-lock.json b/package-lock.json
index 257e7ed..b00568a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4409,9 +4409,9 @@
       }
     },
     "node_modules/async": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
-      "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
       "dev": true,
       "dependencies": {
         "lodash": "^4.17.14"
@@ -7058,12 +7058,12 @@
       "dev": true
     },
     "node_modules/ejs": {
-      "version": "3.1.6",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
-      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz",
+      "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==",
       "dev": true,
       "dependencies": {
-        "jake": "^10.6.1"
+        "jake": "^10.8.5"
       },
       "bin": {
         "ejs": "bin/cli.js"
@@ -8344,12 +8344,33 @@
       }
     },
     "node_modules/filelist": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
-      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz",
+      "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
       "dev": true,
       "dependencies": {
-        "minimatch": "^3.0.4"
+        "minimatch": "^5.0.1"
+      }
+    },
+    "node_modules/filelist/node_modules/brace-expansion": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+      "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/filelist/node_modules/minimatch": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+      "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
       }
     },
     "node_modules/filesize": {
@@ -10151,13 +10172,13 @@
       }
     },
     "node_modules/jake": {
-      "version": "10.8.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
-      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "version": "10.8.5",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+      "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==",
       "dev": true,
       "dependencies": {
-        "async": "0.9.x",
-        "chalk": "^2.4.2",
+        "async": "^3.2.3",
+        "chalk": "^4.0.2",
         "filelist": "^1.0.1",
         "minimatch": "^3.0.4"
       },
@@ -10165,62 +10186,15 @@
         "jake": "bin/cli.js"
       },
       "engines": {
-        "node": "*"
-      }
-    },
-    "node_modules/jake/node_modules/ansi-styles": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
-      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-      "dev": true,
-      "dependencies": {
-        "color-convert": "^1.9.0"
-      },
-      "engines": {
-        "node": ">=4"
+        "node": ">=10"
       }
     },
     "node_modules/jake/node_modules/async": {
-      "version": "0.9.2",
-      "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
-      "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=",
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+      "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==",
       "dev": true
     },
-    "node_modules/jake/node_modules/chalk": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-      "dev": true,
-      "dependencies": {
-        "ansi-styles": "^3.2.1",
-        "escape-string-regexp": "^1.0.5",
-        "supports-color": "^5.3.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
-    "node_modules/jake/node_modules/escape-string-regexp": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
-      "dev": true,
-      "engines": {
-        "node": ">=0.8.0"
-      }
-    },
-    "node_modules/jake/node_modules/supports-color": {
-      "version": "5.5.0",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-      "dev": true,
-      "dependencies": {
-        "has-flag": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/jest": {
       "version": "27.4.5",
       "resolved": "https://registry.npmjs.org/jest/-/jest-27.4.5.tgz",
@@ -22277,9 +22251,9 @@
       "dev": true
     },
     "async": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
-      "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+      "version": "2.6.4",
+      "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
+      "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
       "dev": true,
       "requires": {
         "lodash": "^4.17.14"
@@ -24293,12 +24267,12 @@
       "dev": true
     },
     "ejs": {
-      "version": "3.1.6",
-      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
-      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "version": "3.1.7",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.7.tgz",
+      "integrity": "sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==",
       "dev": true,
       "requires": {
-        "jake": "^10.6.1"
+        "jake": "^10.8.5"
       }
     },
     "electron-to-chromium": {
@@ -25283,12 +25257,32 @@
       }
     },
     "filelist": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
-      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.3.tgz",
+      "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
       "dev": true,
       "requires": {
-        "minimatch": "^3.0.4"
+        "minimatch": "^5.0.1"
+      },
+      "dependencies": {
+        "brace-expansion": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+          "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+          "dev": true,
+          "requires": {
+            "balanced-match": "^1.0.0"
+          }
+        },
+        "minimatch": {
+          "version": "5.0.1",
+          "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz",
+          "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==",
+          "dev": true,
+          "requires": {
+            "brace-expansion": "^2.0.1"
+          }
+        }
       }
     },
     "filesize": {
@@ -26591,57 +26585,22 @@
       }
     },
     "jake": {
-      "version": "10.8.2",
-      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
-      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "version": "10.8.5",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz",
+      "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==",
       "dev": true,
       "requires": {
-        "async": "0.9.x",
-        "chalk": "^2.4.2",
+        "async": "^3.2.3",
+        "chalk": "^4.0.2",
         "filelist": "^1.0.1",
         "minimatch": "^3.0.4"
       },
       "dependencies": {
-        "ansi-styles": {
-          "version": "3.2.1",
-          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
-          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
-          "dev": true,
-          "requires": {
-            "color-convert": "^1.9.0"
-          }
-        },
         "async": {
-          "version": "0.9.2",
-          "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
-          "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=",
-          "dev": true
-        },
-        "chalk": {
-          "version": "2.4.2",
-          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
-          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
-          "dev": true,
-          "requires": {
-            "ansi-styles": "^3.2.1",
-            "escape-string-regexp": "^1.0.5",
-            "supports-color": "^5.3.0"
-          }
-        },
-        "escape-string-regexp": {
-          "version": "1.0.5",
-          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
-          "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+          "version": "3.2.3",
+          "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+          "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==",
           "dev": true
-        },
-        "supports-color": {
-          "version": "5.5.0",
-          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
-          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
-          "dev": true,
-          "requires": {
-            "has-flag": "^3.0.0"
-          }
         }
       }
     },
diff --git a/package.json b/package.json
index 6897da6..fa54820 100644
--- a/package.json
+++ b/package.json
@@ -57,12 +57,13 @@
     "format": "prettier --write --no-error-on-unmatched-pattern 'src/**/*.{js,jsx,ts,tsx,json,css,md}'",
     "version": "node scripts/changelog.mjs && git checkout -b \"prepare-v${npm_package_version}-$(date +%s)\" && git add --all && git commit --message \"chore(release): prepare v${npm_package_version}\" && git push --set-upstream origin $(git branch --show-current)",
     "postversion": "which gh && gh pr create --title \"chore(release): prepare v${npm_package_version}\" --body \"Prepares the v${npm_package_version} release.\" --assignee @me --label release --repo joinmarket-webui/joinmarket-webui --draft",
-    "regtest:rebuild": "npm run regtest:clear && docker-compose --file docker/regtest/docker-compose.yml build --pull --no-cache",
-    "regtest:clear": "docker-compose --file docker/regtest/docker-compose.yml down --volumes --remove-orphans",
-    "regtest:up": "docker-compose --file docker/regtest/docker-compose.yml up",
-    "regtest:down": "docker-compose --file docker/regtest/docker-compose.yml down",
+    "regtest:rebuild": "npm run regtest:clear && docker-compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull --no-cache",
+    "regtest:clear": "docker-compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down --volumes --remove-orphans",
+    "regtest:up": "./docker/regtest/prepare-setup.sh && docker-compose --env-file docker/regtest/.env.generated --file docker/regtest/docker-compose.yml up",
+    "regtest:down": "docker-compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down",
     "regtest:logs:jmwalletd": "docker exec -t jm_regtest_joinmarket tail -f /root/.joinmarket/logs/jmwalletd_stdout.log",
     "regtest:logs:schedule": "docker exec -t jm_regtest_joinmarket tail -f /root/.joinmarket/logs/TUMBLE.schedule",
+    "regtest:logs:directory_node": "docker exec -t jm_regtest_joinmarket_directory_node tail -f /root/.joinmarket/logs/directory_node_stdout.log -n 200",
     "regtest:init": "./docker/regtest/init-setup.sh",
     "regtest:mine": "watch -n 10 ./docker/regtest/mine-block.sh"
   },
diff --git a/src/components/App.jsx b/src/components/App.jsx
index 480794f..eaeaec6 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -23,6 +23,7 @@ import Onboarding from './Onboarding'
 import Cheatsheet from './Cheatsheet'
 import { routes } from '../constants/routes'
 import { isFeatureEnabled } from '../constants/featureFlags'
+import packageInfo from '../../package.json'
 
 export default function App() {
   const { t } = useTranslation()
@@ -185,6 +186,17 @@ export default function App() {
             )}
           </div>
           <div className="d-flex flex-1 order-2 justify-content-end align-items-center gap-1">
+            <div className="warning-hint text-start text-secondary d-none d-md-block pe-1">
+              <a
+                href="https://github.com/joinmarket-webui/joinmarket-webui/tags"
+                target="_blank"
+                rel="noopener noreferrer"
+                className="d-flex align-items-center text-secondary"
+              >
+                {' '}
+                v{packageInfo.version}
+              </a>
+            </div>
             <div className="d-flex gap-2 pe-2">
               <a
                 href="https://github.com/joinmarket-webui/joinmarket-webui"
diff --git a/src/components/Balance.jsx b/src/components/Balance.jsx
index cc7b002..fba1374 100644
--- a/src/components/Balance.jsx
+++ b/src/components/Balance.jsx
@@ -68,13 +68,25 @@ const BalanceComponent = ({ symbol, value, symbolIsPrefix }) => {
  * @param {showBalance}: A flag indicating whether to render or hide the balance.
  * Hidden balances are masked with `*****`.
  * @param {loading}: A loading flag that renders a placeholder while true.
+ * @param {enableVisibilityToggle}: A flag that controls whether the balance can mask/unmask when clicked
  */
-export default function Balance({ valueString, convertToUnit, showBalance = false, loading = false }) {
+export default function Balance({
+  valueString,
+  convertToUnit,
+  showBalance = false,
+  loading = false,
+  enableVisibilityToggle = !showBalance,
+}) {
   const [displayMode, setDisplayMode] = useState(DISPLAY_MODE_HIDDEN)
+  const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
 
   useEffect(() => {
-    setDisplayMode(getDisplayMode(convertToUnit, showBalance))
-  }, [convertToUnit, showBalance])
+    setIsBalanceVisible(showBalance)
+  }, [showBalance])
+
+  useEffect(() => {
+    setDisplayMode(getDisplayMode(convertToUnit, isBalanceVisible))
+  }, [convertToUnit, isBalanceVisible])
 
   if (loading) {
     return (
@@ -87,43 +99,62 @@ export default function Balance({ valueString, convertToUnit, showBalance = fals
     )
   }
 
-  if (displayMode === DISPLAY_MODE_HIDDEN) {
-    return (
-      <BalanceComponent
-        symbol={
-          <span className="d-inline-flex align-items-center text-muted">
-            <Sprite symbol="hide" width="1.2em" height="1.2em" className="ps-1" />
-          </span>
-        }
-        value={'*****'}
-        symbolIsPrefix={false}
-      />
-    )
-  }
+  const toggleVisibility = (e) => {
+    e.preventDefault()
+    e.stopPropagation()
 
-  if (typeof valueString !== 'string') {
-    console.warn('<Balance /> component expects string input')
-    return <BalanceComponent symbol="" value={valueString} symbolIsPrefix={false} />
+    setIsBalanceVisible((current) => !current)
   }
 
-  // Treat integers as sats.
-  const valueIsSats = valueString === Number.parseInt(valueString).toString()
-  // Treat decimal numbers as btc.
-  const valueIsBtc = !valueIsSats && !Number.isNaN(Number.parseFloat(valueString)) && valueString.indexOf('.') > -1
-
-  const btcSymbol = <span style={{ paddingRight: '0.1em' }}>{'\u20BF'}</span>
-  const satSymbol = <Sprite symbol="sats" width="1.2em" height="1.2em" />
-
-  if (valueIsBtc && displayMode === DISPLAY_MODE_BTC)
-    return <BalanceComponent symbol={btcSymbol} value={formatBtc(valueString)} symbolIsPrefix={true} />
-  if (valueIsSats && displayMode === DISPLAY_MODE_SATS)
-    return <BalanceComponent symbol={satSymbol} value={formatSats(valueString)} symbolIsPrefix={false} />
-
-  if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
-    return <BalanceComponent symbol={satSymbol} value={formatSats(btcToSats(valueString))} symbolIsPrefix={false} />
-  if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
-    return <BalanceComponent symbol={btcSymbol} value={formatBtc(satsToBtc(valueString))} symbolIsPrefix={true} />
+  const balanceComponent = (() => {
+    if (displayMode === DISPLAY_MODE_HIDDEN) {
+      return (
+        <BalanceComponent
+          symbol={
+            <span className="d-inline-flex align-items-center text-muted">
+              <Sprite symbol="hide" width="1.2em" height="1.2em" className="ps-1" />
+            </span>
+          }
+          value={'*****'}
+          symbolIsPrefix={false}
+        />
+      )
+    }
+
+    if (typeof valueString !== 'string') {
+      console.warn('<Balance /> component expects string input')
+      return <BalanceComponent symbol="" value={valueString} symbolIsPrefix={false} />
+    }
+
+    // Treat integers as sats.
+    const valueIsSats = valueString === Number.parseInt(valueString).toString()
+    // Treat decimal numbers as btc.
+    const valueIsBtc = !valueIsSats && !Number.isNaN(Number.parseFloat(valueString)) && valueString.indexOf('.') > -1
+
+    const btcSymbol = <span style={{ paddingRight: '0.1em' }}>{'\u20BF'}</span>
+    const satSymbol = <Sprite symbol="sats" width="1.2em" height="1.2em" />
+
+    if (valueIsBtc && displayMode === DISPLAY_MODE_BTC)
+      return <BalanceComponent symbol={btcSymbol} value={formatBtc(valueString)} symbolIsPrefix={true} />
+    if (valueIsSats && displayMode === DISPLAY_MODE_SATS)
+      return <BalanceComponent symbol={satSymbol} value={formatSats(valueString)} symbolIsPrefix={false} />
+
+    if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
+      return <BalanceComponent symbol={satSymbol} value={formatSats(btcToSats(valueString))} symbolIsPrefix={false} />
+    if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
+      return <BalanceComponent symbol={btcSymbol} value={formatBtc(satsToBtc(valueString))} symbolIsPrefix={true} />
+
+    console.warn('<Balance /> component cannot determine balance format')
+    return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
+  })()
+
+  if (!enableVisibilityToggle) {
+    return <>{balanceComponent}</>
+  }
 
-  console.warn('<Balance /> component cannot determine balance format')
-  return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
+  return (
+    <span onClick={toggleVisibility} style={{ cursor: 'pointer' }}>
+      {balanceComponent}
+    </span>
+  )
 }
diff --git a/src/components/CurrentWalletMagic.jsx b/src/components/CurrentWalletMagic.jsx
index 36d462a..1d9907f 100644
--- a/src/components/CurrentWalletMagic.jsx
+++ b/src/components/CurrentWalletMagic.jsx
@@ -26,7 +26,12 @@ const WalletHeader = ({ name, balance, unit, showBalance, loading }) => {
       )}
       {!loading && (
         <h2>
-          <Balance valueString={balance} convertToUnit={unit} showBalance={showBalance || false} />
+          <Balance
+            valueString={balance}
+            convertToUnit={unit}
+            showBalance={showBalance || false}
+            enableVisibilityToggle={false}
+          />
         </h2>
       )}
     </div>
diff --git a/src/components/Earn.jsx b/src/components/Earn.jsx
index 357fd5e..3897f96 100644
--- a/src/components/Earn.jsx
+++ b/src/components/Earn.jsx
@@ -258,6 +258,11 @@ export default function Earn() {
 
           {serviceInfoAlert && <rb.Alert variant={serviceInfoAlert.variant}>{serviceInfoAlert.message}</rb.Alert>}
 
+          {!serviceInfo?.coinjoinInProgress &&
+            !serviceInfo?.makerRunning &&
+            !isWaitingMakerStart &&
+            !isWaitingMakerStop && <p className="text-secondary mb-4">{t('earn.market_explainer')}</p>}
+
           {!serviceInfo?.coinjoinInProgress && (
             <Formik initialValues={initialValues} validate={validate} onSubmit={onSubmit}>
               {({ handleSubmit, setFieldValue, handleChange, handleBlur, values, touched, errors, isSubmitting }) => (
@@ -312,7 +317,16 @@ export default function Earn() {
                         </rb.Form.Group>
                       ) : (
                         <rb.Form.Group className="mb-3" controlId="feeAbs">
-                          <rb.Form.Label className="mb-0">{t('earn.label_abs_fee')}</rb.Form.Label>
+                          <rb.Form.Label className="mb-0">
+                            {t('earn.label_abs_fee', {
+                              fee:
+                                '(' +
+                                (values.feeAbs === '' ? 0 : values.feeAbs) +
+                                ' ' +
+                                (values.feeAbs === 1 ? 'sat' : 'sats') +
+                                ')',
+                            })}
+                          </rb.Form.Label>
                           <div className="mb-2">
                             <rb.Form.Text className="text-secondary">{t('earn.description_abs_fee')}</rb.Form.Text>
                           </div>
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index 02b5ee9..f474ffc 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from 'react'
+import React, { useState, useEffect, useCallback, useMemo } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Formik, useFormikContext } from 'formik'
@@ -13,6 +13,14 @@ import Sprite from './Sprite'
 import Balance from './Balance'
 import ScheduleProgress from './ScheduleProgress'
 
+// 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]
+// Interval in milliseconds between requests to reload the schedule.
+const SCHEDULE_REQUEST_INTERVAL = process.env.NODE_ENV === 'development' ? 10_000 : 60_000
+const SCHEDULER_STOP_RESPONSE_DELAY_MS = 2_000
+
 const ValuesListener = ({ handler }) => {
   const { values } = useFormikContext()
 
@@ -40,15 +48,55 @@ export default function Jam() {
   const [collaborativeOperationRunning, setCollaborativeOperationRunning] = useState(false)
   const [schedule, setSchedule] = useState(null)
 
+  // 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.')
+      }
+      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 newEntry = externalBranch.entries.find((entry) => entry.status === 'new')
+
+        if (!newEntry) {
+          throw new Error(`Cannot find a fresh address in mixdepth ${mixdepth}`)
+        }
+
+        return newEntry.address
+      })
+    },
+    [walletInfo]
+  )
+
   // Todo: Testing toggle is deactivated until https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1260 is merged.
   const deactivateTestingToggle = true
   const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false)
 
-  // Todo: Discuss if we should hardcode this or let the user pick an account.
-  const INTERNAL_DEST_ACCOUNT = 0
-  // Interval in milliseconds between requests to reload the schedule.
-  const SCHEDULE_REQUEST_INTERVAL = process.env.NODE_ENV === 'development' ? 10_000 : 60_000
-  const SCHEDULER_STOP_RESPONSE_DELAY_MS = 2_000
+  const initialFormValues = useMemo(() => {
+    const addressCount = 3
+
+    let destinationAddresses = []
+    if (destinationIsExternal) {
+      // prefill with empty addresses
+      destinationAddresses = Array(addressCount).fill('')
+    } else {
+      try {
+        // prefill with addresses marked as "new"
+        destinationAddresses = getNewAddressesForAccounts(INTERNAL_DEST_ACCOUNTS)
+      } catch (e) {
+        // on error initialize with empty addresses - form validation will do the rest
+        destinationAddresses = Array(addressCount).fill('')
+      }
+    }
+
+    return destinationAddresses.reduce((obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }), {})
+  }, [destinationIsExternal, getNewAddressesForAccounts])
 
   useEffect(() => {
     const abortCtrl = new AbortController()
@@ -121,29 +169,7 @@ export default function Jam() {
       clearInterval(interval)
       abortCtrl.abort()
     }
-  }, [collaborativeOperationRunning, reloadSchedule, SCHEDULE_REQUEST_INTERVAL])
-
-  const getNewAddresses = (count, mixdepth) => {
-    const externalBranch = walletInfo.data.display.walletinfo.accounts[mixdepth].branches.find((branch) => {
-      return branch.branch.split('\t')[0] === 'external addresses'
-    })
-
-    const newAddresses = []
-
-    externalBranch.entries.every((entry) => {
-      if (entry.status === 'new') {
-        newAddresses.push(entry.address)
-      }
-
-      if (newAddresses.length >= count) {
-        return false
-      }
-
-      return true
-    })
-
-    return newAddresses
-  }
+  }, [collaborativeOperationRunning, reloadSchedule])
 
   const startSchedule = async (values) => {
     if (isLoading || collaborativeOperationRunning) {
@@ -249,10 +275,7 @@ export default function Jam() {
           {((!collaborativeOperationRunning && walletInfo && serviceInfo) ||
             (collaborativeOperationRunning && schedule)) && (
             <Formik
-              initialValues={getNewAddresses(3, INTERNAL_DEST_ACCOUNT).reduce(
-                (obj, addr, index) => ({ ...obj, [`dest${index + 1}`]: addr }),
-                {}
-              )}
+              initialValues={initialFormValues}
               validate={(values) => {
                 if (collaborativeOperationRunning) {
                   return {}
@@ -274,6 +297,15 @@ export default function Jam() {
                   errors.dest3 = t('scheduler.error_invalid_destionation_address')
                 }
 
+                const validAddresses = Array(3)
+                  .fill('')
+                  .map((_, index) => values[`dest${index + 1}`])
+                  .filter((it) => isValidAddress(it))
+                const uniqueValidAddresses = [...new Set(validAddresses)]
+                if (validAddresses.length !== uniqueValidAddresses.length) {
+                  errors.addressReuse = t('scheduler.error_address_reuse')
+                }
+
                 return errors
               }}
               onSubmit={async (values) => {
@@ -305,23 +337,28 @@ export default function Jam() {
                         <rb.Form.Group className="mb-4" controlId="offertype">
                           <ToggleSwitch
                             label={t('scheduler.toggle_internal_destination_title')}
-                            subtitle={t('scheduler.toggle_internal_destination_subtitle', {
-                              account: INTERNAL_DEST_ACCOUNT,
-                            })}
+                            subtitle={t('scheduler.toggle_internal_destination_subtitle')}
                             initialValue={destinationIsExternal}
                             onToggle={async (isToggled) => {
-                              setDestinationIsExternal(isToggled)
-
                               if (!isToggled) {
-                                const newAddresses = getNewAddresses(3, INTERNAL_DEST_ACCOUNT)
-                                setFieldValue('dest1', newAddresses[0], true)
-                                setFieldValue('dest2', newAddresses[1], true)
-                                setFieldValue('dest3', newAddresses[2], true)
+                                try {
+                                  const newAddresses = getNewAddressesForAccounts(INTERNAL_DEST_ACCOUNTS)
+                                  setFieldValue('dest1', newAddresses[0], true)
+                                  setFieldValue('dest2', newAddresses[1], true)
+                                  setFieldValue('dest3', newAddresses[2], true)
+                                } catch (e) {
+                                  console.error('Could not get internal addresses.', e)
+                                  setFieldValue('dest1', '', true)
+                                  setFieldValue('dest2', '', true)
+                                  setFieldValue('dest3', '', true)
+                                }
                               } else {
                                 setFieldValue('dest1', '', false)
                                 setFieldValue('dest2', '', false)
                                 setFieldValue('dest3', '', false)
                               }
+
+                              setDestinationIsExternal(isToggled)
                             }}
                             disabled={isSubmitting}
                           />
@@ -359,6 +396,7 @@ export default function Jam() {
                           </rb.Form.Group>
                         )
                       })}
+                    {errors && errors.addressReuse && <rb.Alert variant="danger">{errors.addressReuse}</rb.Alert>}
                     {!collaborativeOperationRunning && (
                       <p className="text-secondary mb-4">{t('scheduler.description_fees')}</p>
                     )}
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 86118eb..5bfa04e 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -24,6 +24,7 @@ const WalletPreview = ({ wallet, walletInfo, unit, showBalance }) => {
               valueString={walletInfo.data.display.walletinfo.total_balance}
               convertToUnit={unit}
               showBalance={showBalance || false}
+              enableVisibilityToggle={false}
             />
           </div>
         ) : (
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index c434e53..96e31b4 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -1,5 +1,4 @@
-import React, { useEffect } from 'react'
-import { useState } from 'react'
+import React, { useEffect, useState, useMemo } from 'react'
 import { Link, useLocation } from 'react-router-dom'
 import { Trans, useTranslation } from 'react-i18next'
 import * as rb from 'react-bootstrap'
@@ -176,32 +175,31 @@ export default function Send() {
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
   const settings = useSettings()
-
   const location = useLocation()
-  const [alert, setAlert] = useState(null)
 
-  const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)
-  const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
-  const [waitForTakerToFinish, setWaitForTakerToFinish] = useState(false)
-  const [takerStartedInfoAlert, setTakerStartedInfoAlert] = useState(null)
+  const isCoinjoinInProgress = useMemo(() => serviceInfo && serviceInfo.coinjoinInProgress, [serviceInfo])
+  const isMakerRunning = useMemo(() => serviceInfo && serviceInfo.makerRunning, [serviceInfo])
+  const waitForTakerToFinish = useMemo(() => isCoinjoinInProgress, [isCoinjoinInProgress])
+  const isOperationDisabled = useMemo(
+    () => isCoinjoinInProgress || isMakerRunning,
+    [isCoinjoinInProgress, isMakerRunning]
+  )
 
+  const [alert, setAlert] = useState(null)
   const [isLoading, setIsLoading] = useState(true)
   const [isSending, setIsSending] = useState(false)
-  const [isOperationDisabled, setIsOperationDisabled] = useState(false)
-
-  useEffect(() => {
-    const coinjoinInProgress = serviceInfo && serviceInfo.coinjoinInProgress
-    const makerRunning = serviceInfo && serviceInfo.makerRunning
-    setIsOperationDisabled(makerRunning || coinjoinInProgress)
-
-    setWaitForTakerToFinish(coinjoinInProgress)
-    setTakerStartedInfoAlert((current) => (coinjoinInProgress ? current : null))
-  }, [serviceInfo])
-
   const [isCoinjoin, setIsCoinjoin] = useState(IS_COINJOIN_DEFAULT_VAL)
   const [minNumCollaborators, setMinNumCollaborators] = useState(MINIMUM_MAKERS_DEFAULT_VAL)
   const [isSweep, setIsSweep] = useState(false)
 
+  const [waitForUtxosToBeSpent, setWaitForUtxosToBeSpent] = useState([])
+  const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState(null)
+  const [takerStartedInfoAlert, setTakerStartedInfoAlert] = useState(null)
+
+  useEffect(() => {
+    setTakerStartedInfoAlert((current) => (isCoinjoinInProgress ? current : null))
+  }, [isCoinjoinInProgress])
+
   const initialDestination = null
   const initialAccount = 0
   const initialAmount = null
@@ -416,7 +414,7 @@ export default function Send() {
   const onSubmit = async (e) => {
     e.preventDefault()
 
-    if (isOperationDisabled) return
+    if (isLoading || isOperationDisabled) return
 
     const form = e.currentTarget
     const isValid = formIsValid
@@ -574,14 +572,12 @@ export default function Send() {
   return (
     <>
       <div
-        className={`${serviceInfo?.makerRunning ? styles['maker-running'] : ''} ${
-          serviceInfo?.coinjoinInProgress ? 'taker-running' : ''
-        }`}
+        className={`${isMakerRunning ? styles['maker-running'] : ''} ${isCoinjoinInProgress ? 'taker-running' : ''}`}
       >
         <PageTitle title={t('send.title')} subtitle={t('send.subtitle')} />
         <rb.Fade in={isOperationDisabled} mountOnEnter={true} unmountOnExit={true}>
           <>
-            {serviceInfo?.makerRunning && (
+            {isMakerRunning && (
               <Link to={routes.earn} className={styles.unstyled}>
                 <rb.Alert variant="info" className="mb-4">
                   <rb.Row className="align-items-center">
@@ -593,7 +589,7 @@ export default function Send() {
                 </rb.Alert>
               </Link>
             )}
-            {serviceInfo?.coinjoinInProgress && (
+            {isCoinjoinInProgress && (
               <rb.Alert variant="info" className="mb-4">
                 {t('send.text_coinjoin_already_running')}
               </rb.Alert>
@@ -717,6 +713,7 @@ export default function Send() {
           <rb.Form.Group controlId="isCoinjoin" className={`${isCoinjoin ? 'mb-3' : ''}`}>
             <ToggleSwitch
               label={t('send.toggle_coinjoin')}
+              subtitle={t('send.toggle_coinjoin_subtitle')}
               initialValue={isCoinjoin}
               onToggle={(isToggled) => setIsCoinjoin(isToggled)}
               disabled={isLoading || isOperationDisabled}
@@ -732,7 +729,7 @@ export default function Send() {
           />
         )}
         <rb.Button
-          variant="dark"
+          variant={isCoinjoin ? 'dark' : 'danger'}
           type="submit"
           disabled={isOperationDisabled || isLoading || isSending || !formIsValid}
           className={`${styles['button']} ${styles['send-button']} mt-4`}
@@ -743,8 +740,10 @@ export default function Send() {
               <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
               {t('send.text_sending')}
             </div>
-          ) : (
+          ) : isCoinjoin ? (
             t('send.button_send')
+          ) : (
+            t('send.button_send_without_improved_privacy')
           )}
         </rb.Button>
       </div>
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 985e618..fef179a 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -24,23 +24,23 @@
   },
   "onboarding": {
     "splashscreen_title": "Jam",
-    "splashscreen_subtitle": "A friendly UI for JoinMarket",
+    "splashscreen_subtitle": "Privacy for Yourself and Others",
     "splashscreen_button_get_started": "Get started",
     "splashscreen_button_skip_intro": "Skip intro",
     "splashscreen_description_line1": "Your wallet, your coins.",
     "splashscreen_description_line2": "100% open-source & open-design.",
     "splashscreen_warning_title": "Warning",
-    "splashscreen_warning_text": "While JoinMarket is tried and tested, Jam is not. It is in an alpha stage, so use with caution. <1>Help us improve the project on GitHub.</1>",
+    "splashscreen_warning_text": "While JoinMarket is tried and tested, Jam is anything but. It is alpha software, so please use it with caution. You can <1>help to improve the project on GitHub</1>.",
     "screen_1_title": "Welcome to Jam for JoinMarket!",
-    "screen_1_description": "JoinMarket is a privacy-focused software solution that aims to improve the confidentiality and privacy of your bitcoin transactions. It facilitates the creation of collaborative transactions through a peer-to-peer marketplace.",
+    "screen_1_description": "Jam is an interface for JoinMarket, a privacy-focused software solution that aims to improve the confidentiality and privacy of your bitcoin transactions. Its main focus is to facilitate the creation of collaborative transactions through a peer-to-peer marketplace.",
     "screen_2_title": "Collaborative Transactions",
-    "screen_2_description": "To have strong privacy guarantees in the open and transparent world of bitcoin, special kinds of transactions have to be created. JoinMarket helps you to create these transactions in an easy and automated way.",
+    "screen_2_description": "To have strong privacy guarantees in the open and transparent world of bitcoin, special kinds of transactions have to be created. Jam helps you to create these transactions in an easy and automated way.",
     "screen_3_title": "You Are In Control",
-    "screen_3_description": "JoinMarket is fully non-custodial, meaning that you always have full control over your funds. The system uses Bitcoin’s smart contracts to make sure that all transactions are atomic and your funds are secure at all times.",
+    "screen_3_description": "Jam is fully non-custodial, meaning that you always have full control over your funds. JoinMarket uses Bitcoin's smart contracts to make sure that all transactions are atomic and your funds are secure at all times.",
     "screen_4_title": "No Trusted Third Parties",
     "screen_4_description": "Since JoinMarket is a peer-to-peer system, trusted third parties are eliminated from the get-go. This unique market-driven approach reduces counterparty risk to a minimum.",
     "screen_5_title": "Privacy for All",
-    "screen_5_description": "JoinMarket is free and open-source software without a single point of failure. Everyone is free to use it and build upon it.",
+    "screen_5_description": "JoinMarket is free and open-source software without a single point of failure. Everyone is free to extend it, tinker with it, inspect it, distribute it, and build upon it. And, of course, you are free to use it to improve the privacy of yourself and others.",
     "button_next": "Next",
     "button_complete": "Let's go!"
   },
@@ -151,11 +151,11 @@
   },
   "send": {
     "loading": "Loading",
-    "title": "Send bitcoin",
-    "subtitle": "Collaborative transactions increase the privacy of yourself and others.",
+    "title": "Send",
+    "subtitle": "Use a collaborative transaction for increased security and privacy.",
     "label_num_collaborators": "Number of collaborators: {{ numCollaborators }}",
     "input_num_collaborators_placeholder": "Other",
-    "description_num_collaborators": "A higher number is better for privacy, but also increases the fee.",
+    "description_num_collaborators": "A higher number is better for privacy, but also increases the transaction fee.",
     "error_invalid_num_collaborators": "Please use between {{ minNumCollaborators }} and {{ maxNumCollaborators }} collaborators.",
     "taker_error_message_max_fees_config_missing": "Config variables 'max_cj_fee_rel' and 'max_cj_fee_abs' must be set in your joinmarket.cfg in order to send collaborative transactions. Consider adding them to your config manually.",
     "direct_payment_error_message_bad_request": "Please verify your inputs carefully.",
@@ -175,8 +175,10 @@
     "label_amount": "Amount in sats",
     "placeholder_amount": "Enter amount...",
     "feedback_invalid_amount": "Please provide a valid amount.",
-    "toggle_coinjoin": "Send as collaborative transaction for improved privacy",
+    "toggle_coinjoin": "Send as collaborative transaction",
+    "toggle_coinjoin_subtitle": "Collaborative transactions improve the privacy of yourself and others.",
     "button_send": "Send",
+    "button_send_without_improved_privacy": "Send without privacy improvement",
     "text_sending": "Sending",
     "button_sweep": "Sweep",
     "button_clear_sweep": "Clear",
@@ -188,12 +190,12 @@
   },
   "receive": {
     "title": "Receive",
-    "subtitle": "Send bitcoin to the address below.",
-    "error_loading_address_failed": "Loading new address failed.",
+    "subtitle": "Send sats to the address below to fund your wallet.",
+    "error_loading_address_failed": "Unable to get new address.",
     "error_copy_address_failed": "Could not copy address.",
     "button_copy_address": "Copy",
     "text_copy_address_confirmed": "Copied",
-    "button_settings": "Settings",
+    "button_settings": "Advanced",
     "label_choose_account": "Choose account",
     "account_selector_option_dev_mode": "Account {{ number }}",
     "label_amount": "Amount to request in sats",
@@ -202,22 +204,23 @@
     "text_getting_address": "Getting new address"
   },
   "earn": {
-    "title": "Earn bitcoin",
-    "subtitle": "By making your bitcoin available for others, you help them improve their privacy and can also earn a yield.",
+    "title": "Earn",
+    "subtitle": "By providing liquidity to the market, you improve the privacy of yourself and others. Your sats always remain under your control and never leave your wallet.",
+    "market_explainer": "You can choose how much you want to charge for your offer. Note that you participate and compete in an open market—there is no guarantee that other market participants will take you up on your offer. Being chosen for a collaborative transaction can take some time. Low fees increase your chances.",
     "alert_empty_report": "The report is empty.",
     "text_report_length": "Showing {{ visibleLines }} of {{ linesWithoutHeader }} entries",
-    "error_loading_report_failed": "Failed to load yield generator report.",
+    "error_loading_report_failed": "Failed to load earnings report.",
     "alert_starting": "Service is starting.",
     "alert_stopping": "Service is stopping.",
-    "alert_running": "Service is running.",
+    "alert_running": "Earning sats...",
     "alert_coinjoin_in_progress": "A collaborative transaction is currently in progress.",
     "toggle_rel_offer": "Relative offer",
     "label_rel_fee": "Relative fee {{ fee }}",
-    "description_rel_fee": "As a percentage of the amounts you help others with improved privacy.",
+    "description_rel_fee": "The fee to charge others, as a percentage of the transaction amount.",
     "feedback_invalid_rel_fee": "Please provide a relative fee between {{feeRelPercentageMin}} and {{feeRelPercentageMax}}.",
-    "label_abs_fee": "Absolute fee in sats",
-    "description_abs_fee": "An absolute amount you get for helping others to improve their privacy.",
-    "feedback_invalid_abs_fee": "Please provide an absolute fee.",
+    "label_abs_fee": "Absolute fee {{ fee }}",
+    "description_abs_fee": "The fee to charge others, as an absolute amount denominated in sats.",
+    "feedback_invalid_abs_fee": "Invalid absolute fee.",
     "label_min_amount": "Minimum amount in sats",
     "feedback_invalid_min_amount": "Please provide a minimum amount.",
     "button_start": "Start",
@@ -259,7 +262,7 @@
   },
   "scheduler": {
     "title": "Jam Scheduler (Experimental)",
-    "subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of your funds. Every scheduled transaction is a collaborative transaction. The scheduler will use all available funds that aren't frozen.",
+    "subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of yourself and others. Every scheduled transaction is a collaborative transaction. The scheduler will use all available funds that aren't frozen.",
     "error_starting_schedule_failed": "Starting the scheduler failed.",
     "error_stopping_schedule_failed": "Stopping the scheduler failed.",
     "complete_wallet_title": "Complete Wallet",
@@ -273,11 +276,12 @@
     "description_fees": "The scheduler will pay all occurring fees automatically.",
     "button_start": "Start Scheduler",
     "button_stop": "Stop Scheduler",
-    "progress_tldr_seconds": "Currently running a schedule of <1>{{ length }}</1> transactions over <3>{{ seconds }}</3> seconds.",
-    "progress_tldr_hours": "Currently running a schedule of <1>{{ length }}</1> transactions over <3>{{ hours }}</3> hours.",
-    "progress_description": "This time estimate does not take into account the time spent talking to counterparties and waiting for transactions to get confirmed on the time chain. It is a lower bound estimate. The actual time for processing the whole schedule will likely be longer.",
+    "progress_tldr_seconds": "Scheduled <1>{{ length }}</1> transactions over <3>{{ seconds }}</3> seconds.",
+    "progress_tldr_hours": "Scheduled <1>{{ length }}</1> transactions over <3>{{ hours }}</3> hours.",
+    "progress_description": "This estimate is the minimum waiting time. Additional delays due to network communication or transaction confirmation are not considered.",
     "progress_current_state": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> to process...",
     "progress_done": "All transactions completed successfully. The scheduler will stop soon.",
-    "error_loading_schedule_failed": "Loading schedule progress failed."
+    "error_loading_schedule_failed": "Loading schedule progress failed.",
+    "error_address_reuse": "Reusing addresses is prohibited. Make sure to use unique and unused addresses."
   }
 }
diff --git a/src/index.css b/src/index.css
index 9d29e5b..6f4fa89 100644
--- a/src/index.css
+++ b/src/index.css
@@ -502,6 +502,10 @@ h2 {
     center/1em auto no-repeat;
 }
 
+.placeholder {
+  background-color: var(--bs-gray-400);
+}
+
 /* Privacy Level Styles */
 
 .privacy-level-4 {
@@ -610,10 +614,6 @@ h2 {
   --bs-black: #000;
 }
 
-.placeholder {
-  background-color: #e9ecef;
-}
-
 :root[data-theme='dark'] .border-top,
 :root[data-theme='dark'] .border-bottom,
 :root[data-theme='dark'] .border-left,


Test patch:
diff --git a/docker/regtest/.env.example b/docker/regtest/.env.example
new file mode 100644
index 0000000..a87c882
--- /dev/null
+++ b/docker/regtest/.env.example
@@ -0,0 +1,2 @@
+JM_DIRECTORY_NODES=replaceme
+
diff --git a/docker/regtest/.gitignore b/docker/regtest/.gitignore
new file mode 100644
index 0000000..e3fc57e
--- /dev/null
+++ b/docker/regtest/.gitignore
@@ -0,0 +1,4 @@
+
+out/
+.tmp/
+.env.generated
diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml
index af072e9..4f02d3d 100644
--- a/docker/regtest/docker-compose.yml
+++ b/docker/regtest/docker-compose.yml
@@ -20,6 +20,14 @@ services:
       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
+      jm_socks5_host: tor
+      jm_socks5_port: 9050
+      jm_tor_control_host: tor
+      jm_tor_control_port: 9051
+      jm_onion_socks5_host: tor
+      jm_onion_socks5_port: 9050
     expose:
       - 8080  # payjoin server
       - 62601 # obwatch
@@ -35,6 +43,7 @@ services:
       - "nbxplorer_datadir:/root/.nbxplorer"
     depends_on:
       - bitcoind
+      - tor
       - nbxplorer
       - irc
 
@@ -58,7 +67,7 @@ services:
       jm_rpc_port: 43782
       jm_rpc_user: joinmarket2
       jm_rpc_password: joinmarket2
-      # showcase exemplary tor environment vars
+      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_socks5_host: tor
       jm_socks5_port: 9050
@@ -83,9 +92,24 @@ services:
       - "tor_datadir:/home/tor/.tor"
     depends_on:
       - bitcoind
-      - irc
       - nbxplorer
       - tor
+      - irc
+
+  joinmarket_directory_node:
+    container_name: jm_regtest_joinmarket_directory_node
+    build:
+      context: ./dockerfile-deps/joinmarket/directory_node
+      dockerfile: Dockerfile
+    restart: unless-stopped
+    environment:
+      jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir
+      jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file}
+      jm_blockchain_source: regtest
+      jm_network: testnet
+    volumes:
+      - "joinmarket_directory_node_datadir:/root/.joinmarket"
+      - "./out/hidden_service_dir:/root/.joinmarket/hidden_service_dir:z"
 
   irc:
     container_name: jm_regtest_irc
@@ -209,6 +233,7 @@ volumes:
   nbxplorer_datadir:
   joinmarket_datadir:
   joinmarket2_datadir:
+  joinmarket_directory_node_datadir:
   irc_datadir:
   tor_datadir:
   tor_torrcdir:
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile b/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile
new file mode 100644
index 0000000..8eced3d
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/Dockerfile
@@ -0,0 +1,49 @@
+FROM python:3.9.7-slim-bullseye
+
+RUN apt-get update \
+    && apt-get install -qq --no-install-recommends tini procps vim git iproute2 supervisor \
+       curl build-essential automake pkg-config libtool python3-dev python3-pip python3-setuptools libltdl-dev \
+       libevent-dev libssl-dev zlib1g-dev \
+    && rm -rf /var/lib/apt/lists/*
+
+ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver
+ENV REPO_BRANCH master
+ENV REPO_REF master
+
+WORKDIR /src
+RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF"
+
+RUN ./install.sh --docker-install --with-local-tor --disable-secp-check --without-qt
+
+ENV DATADIR /root/.joinmarket
+ENV CONFIG ${DATADIR}/joinmarket.cfg
+ENV DEFAULT_CONFIG /root/default.cfg
+ENV DEFAULT_AUTO_START /root/autostart
+ENV AUTO_START ${DATADIR}/autostart
+ENV PATH /src/scripts:$PATH
+
+COPY default.cfg "${CONFIG}"
+COPY autostart /root/
+COPY supervisor-conf/*.conf /etc/supervisor/conf.d/
+
+RUN cd /src/scripts \
+    && (python wallet-tool.py generate || true) \
+    && cp "${CONFIG}" "${DEFAULT_CONFIG}"
+
+WORKDIR /src/scripts
+
+COPY start-dn.py .
+COPY docker-entrypoint.sh .
+
+# payjoin server
+EXPOSE 8080
+# obwatch
+EXPOSE 62601 
+# joinmarketd daemon
+EXPOSE 27183
+# jmwalletd api
+EXPOSE 28183
+# jmwalletd websocket
+EXPOSE 28283
+
+ENTRYPOINT  [ "tini", "-g", "--", "./docker-entrypoint.sh" ]
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/README.md b/docker/regtest/dockerfile-deps/joinmarket/directory_node/README.md
new file mode 100644
index 0000000..24adac4
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/README.md
@@ -0,0 +1,18 @@
+Starts a staging joinmarket directory node that you can point to in your regtest configurations.
+
+- start-dn.py taken from https://github.com/JoinMarket-Org/custom-scripts/blob/e3c5fb548c704fc56cdaa869705797955a9821dd/start-dn.py
+  (might already be in master - last check on 2022-05-11)
+
+You must mount the directory specified in `hidden_service_dir`, which contains hostname, public and private key, 
+and provide the correct onion hostname via `directory_nodes` yourself!
+
+e.g. in a docker-compose setup:
+```yml
+  joinmarket_directory_node:
+    [...]
+    environment:
+      jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir
+      jm_directory_nodes: jamobvon3jdgjnvuxur7vpdr6hzwrxhu5li3pnannfrrrupg5sb6ouyd.onion:5222
+    volumes:
+      - "./my-hidden_service_dir:/root/.joinmarket/hidden_service_dir:z"
+```
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/autostart b/docker/regtest/dockerfile-deps/joinmarket/directory_node/autostart
new file mode 100644
index 0000000..65889d0
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/autostart
@@ -0,0 +1,4 @@
+# Remove comments in front of the services you want to automatically restart
+# when the container restarts.
+
+directory-node
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
new file mode 100644
index 0000000..00237bb
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/default.cfg
@@ -0,0 +1,383 @@
+[DAEMON]
+# set to 1 to run the daemon service within this process;
+# set to 0 if the daemon is run separately (using script joinmarketd.py)
+no_daemon = 1
+
+# Port on which daemon serves; note that communication still
+# occurs over this port even if no_daemon = 1
+daemon_port = 27183
+
+# Currently, running the daemon on a remote host is
+# *NOT* supported, so don't change this variable
+daemon_host = localhost
+
+# by default the client-daemon connection is plaintext, set to 'true' to use TLS;
+# for this, you need to have a valid (self-signed) certificate installed
+use_ssl = false
+
+[BLOCKCHAIN]
+# options: bitcoin-rpc, regtest, bitcoin-rpc-no-history, no-blockchain
+# When using bitcoin-rpc-no-history remember to increase the gap limit to scan for more addresses, try -g 5000
+# Use 'no-blockchain' to run the ob-watcher.py script in scripts/obwatch without current access
+# to Bitcoin Core; note that use of this option for any other purpose is currently unsupported.
+blockchain_source = regtest
+
+# options: signet, testnet, mainnet
+# Note: for regtest, use network = testnet
+network = testnet
+
+rpc_host = localhost
+# default ports are 8332 for mainnet, 18443 for regtest, 18332 for testnet, 38332 for signet
+rpc_port =
+
+# Use either rpc_user / rpc_password pair or rpc_cookie_file.
+rpc_user = bitcoin
+rpc_password = password
+#rpc_cookie_file =
+
+# rpc_wallet_file is Bitcoin Core wallet which is used for address and
+# transaction monitoring (it is watchonly, no private keys are stored there).
+# It must be created manually if does not exist, see docs/USAGE.md for more
+# information.
+rpc_wallet_file =
+
+[MESSAGING:onion]
+# onion based message channels must have the exact type 'onion'
+# (while the section name above can be MESSAGING:whatever), and there must
+# be only ONE such message channel configured (note the directory servers
+# can be multiple, below):
+type = onion
+
+socks5_host = localhost
+socks5_port = 9050
+
+# the tor control configuration.
+# for most people running the tor daemon
+# on Linux, no changes are required here:
+tor_control_host = localhost
+# or, to use a UNIX socket
+# tor_control_host = unix:/var/run/tor/control
+# note: port needs to be provided (but is ignored for UNIX socket)
+tor_control_port = 9051
+
+# the host/port actually serving the hidden service
+# (note the *virtual port*, that the client uses,
+# is hardcoded to as per below 'directory node configuration'.
+onion_serving_host = 127.0.0.1
+onion_serving_port = 8080
+
+# directory node configuration
+# (taken from joinmarket-clientserver/test/regtest_joinmarket.cfg)
+#
+# Special handling on regtest, so just ignore and let the code handle it:
+hidden_service_dir = 
+#
+# This is a comma separated list (comma can be omitted if only one item).
+# Each item has format host:port ; both are required, though port will
+# be 5222 if created in this code.
+# for MAINNET:
+# directory_nodes = 3kxw6lf5vf6y26emzwgibzhrzhmhqiw6ekrek3nqfjjmhwznb2moonad.onion:5222,jmdirjmioywe2s5jad7ts6kgcqg66rj6wujj6q77n6wbdrgocqwexzid.onion:5222,bqlpq6ak24mwvuixixitift4yu42nxchlilrcqwk2ugn45tdclg42qid.onion:5222
+
+# for SIGNET (testing network):
+# directory_nodes = rr6f6qtleiiwic45bby4zwmiwjrj3jsbmcvutwpqxjziaydjydkk5iad.onion:5222,k74oyetjqgcamsyhlym2vgbjtvhcrbxr4iowd4nv4zk5sehw4v665jad.onion:5222,y2ruswmdbsfl4hhwwiqz4m3sx6si5fr6l3pf62d4pms2b53wmagq3eqd.onion:5222
+
+# for REGTEST (local dev setup):
+directory_nodes = replaceme
+
+# This setting is ONLY for developer regtest setups,
+# running multiple bots at once. Don't alter it otherwise
+regtest_count = 0,0
+
+[LOGGING]
+# Set the log level for the output to the terminal/console
+# Possible choices: DEBUG / INFO / WARNING / ERROR
+# Log level for the files in the logs-folder will always be DEBUG
+console_log_level = DEBUG
+
+# Use color-coded log messages to help distinguish log levels?:
+color = true
+
+[TIMEOUT]
+maker_timeout_sec = 60
+unconfirm_timeout_sec = 180
+confirm_timeout_hours = 6
+
+[POLICY]
+# Use segwit style wallets and transactions
+# Only set to false for old wallets, Joinmarket is now segwit only.
+segwit = true
+
+# Use native segwit (bech32) wallet. If set to false, p2sh-p2wkh
+# will be used when generating the addresses for this wallet.
+# Notes: 1. The default joinmarket pit is native segwit.
+#        2. You cannot change the type of a pre-existing wallet.
+native = true
+
+# for dust sweeping, try merge_algorithm = gradual
+# for more rapid dust sweeping, try merge_algorithm = greedy
+# for most rapid dust sweeping, try merge_algorithm = greediest
+# but don't forget to bump your miner fees!
+merge_algorithm = default
+
+# The fee estimate is based on a projection of how many sats/kilo-vbyte
+# are needed to get in one of the next N blocks. N is set here as
+# the value of 'tx_fees'. This cost estimate is high if you set
+# N=1, so we choose 3 for a more reasonable figure, as our default.
+# You can also set your own fee/kilo-vbyte: any number higher than 1 thousand
+# will be interpreted as the fee in sats/kilo-vbyte that you wish to use.
+#
+# Example: N=30000 will use 30 thousand sats/kilo-vbyte (30 sats/vB) as a fee,
+# while N=5 will use the 5 block estimate from your selected blockchain source.
+tx_fees = 3
+
+# Transaction fee rate variance factor, 0.2 means 20% variation around
+# any manually chosen values, so if you set tx_fees=10000 and
+# tx_fees_factor=0.2, it might use any value between 8 thousand and 12 thousand
+# for your transactions.
+tx_fees_factor = 0.2
+
+# For users getting transaction fee estimates over an API,
+# place a sanity check limit on the sats/kilo-vbyte to be paid.
+# This limit is also applied to users using Core, even though
+# Core has its own sanity check limit, which is currently
+# 1 million satoshis.
+#
+# Example: N=350000 will use 350 thousand sats/kilo-vbyte (350 sats/vB) as a
+# maximum fee.
+absurd_fee_per_kb = 350000
+
+# In decimal, the maximum allowable change either lower or
+# higher, that the fee rate used for coinjoin sweeps is
+# allowed to be.
+# (note: coinjoin sweeps *must estimate* fee rates;
+# they cannot be exact due to the lack of change output.)
+#
+# Example: max_sweep_fee_change = 0.4, with tx_fees = 10000,
+# means actual fee rate achieved in the sweep can be as low
+# as 6 thousand sats/kilo-vbyte up to 14 thousand sats/kilo-vbyte.
+#
+# If this is not achieved, the transaction is aborted. For tumbler,
+# it will then be retried until successful.
+# WARNING: too-strict setting may result in using up a lot
+# of PoDLE commitments, hence the default 0.8 (80%).
+max_sweep_fee_change = 0.8
+
+# Maximum absolute coinjoin fee in satoshi to pay to a single
+# 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
+
+# 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%
+
+# The range of confirmations passed to the `listunspent` bitcoind RPC call
+# 1st value is the inclusive minimum, defaults to one confirmation
+# 2nd value is the exclusive maximum, defaults to most-positive-bignum (Google Me!)
+# leaving it unset or empty defers to bitcoind's default values, ie [1, 9999999]
+#listunspent_args = []
+# That's what you should do, unless you have a specific reason, eg:
+#  !!! WARNING !!! CONFIGURING THIS WHILE TAKING LIQUIDITY FROM
+#  !!! WARNING !!! THE PUBLIC ORDERBOOK LEAKS YOUR INPUT MERGES
+#  spend from unconfirmed transactions:  listunspent_args = [0]
+# display only unconfirmed transactions: listunspent_args = [0, 1]
+# defend against small reorganizations:  listunspent_args = [3]
+#   who is at risk of reorganization?:   listunspent_args = [0, 2]
+# NB: using 0 for the 1st value with scripts other than wallet-tool could cause
+# spends from unconfirmed inputs, which may then get malleated or double-spent!
+# other counterparties are likely to reject unconfirmed inputs... don't do it.
+
+# tx_broadcast: options: self, random-peer, not-self.
+#
+# self = broadcast transaction with your own bitcoin node.
+#
+# random-peer = everyone who took part in the coinjoin has a chance of broadcasting
+# Note: if your counterparties do not support it, you will fall back
+# to broadcasting via your own node.
+#
+# not-self = never broadcast with your own bitcoin node.
+#
+# Note: in this case if your counterparties do not broadcast for you, you
+# will have to broadcast the tx manually (you can take the tx hex from the log
+# or terminal) via some other channel. It is not recommended to choose this
+# option when running schedules/tumbler.
+tx_broadcast = random-peer
+
+# If makers do not respond while creating a coinjoin transaction,
+# the non-responding ones will be ignored. This is the minimum
+# amount of makers which we are content with for the coinjoin to
+# succeed. Less makers means that the whole process will restart
+# after a timeout.
+minimum_makers = 4
+
+# Threshold number of satoshis below which an incoming utxo
+# to a reused address in the wallet will be AUTOMATICALLY frozen.
+# This avoids forced address reuse attacks; see:
+# https://en.bitcoin.it/wiki/Privacy#Forced_address_reuse
+#
+# The default is to ALWAYS freeze a utxo to an already used address,
+# whatever the value of it, and this is set with the value -1.
+max_sats_freeze_reuse = -1
+
+# Interest rate used when calculating the value of fidelity bonds created
+# by locking bitcoins in timelocked addresses
+# See also:
+# https://gist.github.com/chris-belcher/87ebbcbb639686057a389acb9ab3e25b#determining-interest-rate-r
+# Set as a real number, i.e. 1 = 100% and 0.01 = 1%
+interest_rate = 0.015
+
+# Some makers run their bots to mix their funds not just to earn money
+# So to improve privacy very slightly takers dont always choose a maker based
+# on his fidelity bond but allow a certain small percentage to be chosen completely
+# randomly without taking into account fidelity bonds
+# This parameter sets how many makers on average will be chosen regardless of bonds
+# A real number, i.e. 1 = 100%, 0.125 = 1/8 = 1 in every 8 makers on average will be bondless
+bondless_makers_allowance = 0.125
+
+# To (strongly) disincentivize Sybil behaviour, the value assessment of the bond
+# is based on the (time value of the bond)^x where x is the bond_value_exponent here,
+# where x > 1. It is a real number (so written as a decimal).
+bond_value_exponent = 1.3
+
+##############################
+# THE FOLLOWING SETTINGS ARE REQUIRED TO DEFEND AGAINST SNOOPERS.
+# DON'T ALTER THEM UNLESS YOU UNDERSTAND THE IMPLICATIONS.
+##############################
+
+# Number of retries allowed for a specific utxo, to prevent DOS/snooping.
+# Lower settings make snooping more expensive, but also prevent honest users
+# from retrying if an error occurs.
+taker_utxo_retries = 3
+
+# Number of confirmations required for the commitment utxo mentioned above.
+# this effectively rate-limits a snooper.
+taker_utxo_age = 5
+
+# Percentage of coinjoin amount that the commitment utxo must have
+# as a minimum BTC amount. Thus 20 means a 1BTC coinjoin requires the
+# utxo to be at least 0.2 btc.
+taker_utxo_amtpercent = 20
+
+# Set to 1 to accept broadcast PoDLE commitments from other bots, and
+# add them to your blacklist (only relevant for Makers).
+# There is no way to spoof these values, so the only "risk" is that
+# someone fills your blacklist file with a lot of data.
+accept_commitment_broadcasts = 1
+
+# Location of your commitments.json file (stores commitments you've used
+# and those you want to use in future), relative to the scripts directory.
+commit_file_location = cmtdata/commitments.json
+
+# Location of the file used by makers to keep track of used/blacklisted
+# commitments. For remote daemon, set to `.` to have it stored locally
+# (but note that *all* bots using the same code installation share it,
+# in this case, which can be bad in testing).
+commitment_list_location = cmtdata/commitmentlist
+
+##############################
+# END OF ANTI-SNOOPING SETTINGS
+##############################
+
+[PAYJOIN]
+# For the majority of situations, the defaults
+# need not be altered - they will ensure you don't pay
+# a significantly higher fee.
+# MODIFICATION OF THESE SETTINGS IS DISADVISED.
+
+# Payjoin protocol version; currently only '1' is supported.
+payjoin_version = 1
+
+# Servers can change their destination address by default (0).
+# if '1', they cannot. Note that servers can explicitly request
+# that this is activated, in which case we respect that choice.
+disable_output_substitution = 0
+
+# "default" here indicates that we will allow the receiver to
+# increase the fee we pay by:
+# 1.2 * (our_fee_rate_per_vbyte * vsize_of_our_input_type)
+# (see https://github.com/bitcoin/bips/blob/master/bip-0078.mediawiki#span_idfeeoutputspanFee_output)
+# (and 1.2 to give breathing room)
+# which indicates we are allowing roughly one extra input's fee.
+# If it is instead set to an integer, then that many satoshis are allowed.
+# Additionally, note that we will also set the parameter additionafeeoutputindex
+# to that of our change output, unless there is none in which case this is disabled.
+max_additional_fee_contribution = default
+
+# This is the minimum sats/vbyte we allow in the payjoin
+# transaction; note it is decimal, not integer.
+min_fee_rate = 1.1
+
+# For payjoins as sender (i.e. client) to hidden service endpoints,
+# the socks5 configuration:
+onion_socks5_host = localhost
+onion_socks5_port = 9050
+
+# For payjoin onion service creation:
+# the tor control configuration:
+tor_control_host = localhost
+
+# or, to use a UNIX socket
+# control_host = unix:/var/run/tor/control
+# note: port needs to be provided (but is ignored for UNIX socket)
+tor_control_port = 9051
+
+# the host/port actually serving the hidden service
+# (note the *virtual port*, that the client uses,
+# is hardcoded to 80):
+onion_serving_host = 127.0.0.1
+onion_serving_port = 8080
+
+# in some exceptional case the HS may be SSL configured,
+# this feature is not yet implemented in code, but here for the
+# future:
+hidden_service_ssl = false
+
+[YIELDGENERATOR]
+# [string, 'reloffer' or 'absoffer'], which fee type to actually use
+ordertype = reloffer
+
+# [satoshis, any integer] / absolute offer fee you wish to receive for coinjoins (cj)
+cjfee_a = 500
+
+# [fraction, any str between 0-1] / relative offer fee you wish to receive based on a cj's amount
+cjfee_r = 0.00002
+
+# [fraction, 0-1] / variance around the average fee. Ex: 200 fee, 0.2 var = fee is btw 160-240
+cjfee_factor = 0.1
+
+# [satoshis, any integer] / the average transaction fee you're adding to coinjoin transactions
+# (note: this will soon be deprecated; leave at zero)
+txfee_contribution = 0
+
+# [fraction, 0-1] / variance around the average fee. Ex: 1000 fee, 0.2 var = fee is btw 800-1200
+txfee_contribution_factor = 0.3
+
+# [satoshis, any integer] / minimum size of your cj offer. Lower cj amounts will be disregarded
+minsize = 100000
+
+# [fraction, 0-1] / variance around all offer sizes. Ex: 500k minsize, 0.1 var = 450k-550k
+size_factor = 0.1
+
+gaplimit = 6
+
+[SNICKER]
+# Any other value than 'true' will be treated as False,
+# and no SNICKER actions will be enabled in that case:
+enabled = false
+
+# In satoshis, we require any SNICKER to pay us at least
+# this much (can be negative), otherwise we will refuse
+# to sign it:
+lowest_net_gain = 0
+
+# Comma separated list of servers (if port is omitted as :port, it
+# is assumed to be 80) which we will poll against (all, in sequence); note
+# that they are allowed to be *.onion or cleartext servers, and no
+# scheme (http(s) etc) needs to be added to the start.
+servers = cn5lfwvrswicuxn3gjsxoved6l2gu5hdvwy5l3ev7kg6j7lbji2k7hqd.onion,
+
+# How many minutes between each polling event to each server above:
+polling_interval_minutes = 60
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/docker-entrypoint.sh b/docker/regtest/dockerfile-deps/joinmarket/directory_node/docker-entrypoint.sh
new file mode 100755
index 0000000..3db8289
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/docker-entrypoint.sh
@@ -0,0 +1,83 @@
+#!/bin/bash
+set -e
+
+# First we restore the default cfg as created by wallet-tool.py generate
+if ! [ -f "$CONFIG" ]; then
+    cp "$DEFAULT_CONFIG" "$CONFIG"
+fi
+
+if ! [ -f "$AUTO_START" ]; then
+    cp "$DEFAULT_AUTO_START" "$AUTO_START"
+fi
+
+# generate ssl certificates for jmwalletd
+if ! [ -f "${DATADIR}/ssl/key.pem" ]; then
+    subj="/C=US/ST=Utah/L=Lehi/O=Your Company, Inc./OU=IT/CN=example.com"
+    mkdir -p "${DATADIR}/ssl/" \
+      && pushd "$_" \
+      && openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem -subj "$subj" \
+      && popd
+fi
+
+# ensure 'logs' directory exists
+mkdir -p "${DATADIR}/logs"
+
+# auto start services
+while read -r p; do
+    [[ "$p" == "" ]] && continue
+    [[ "$p" == "#"* ]] && continue
+    echo "Auto start: $p"
+    file_path="/etc/supervisor/conf.d/$p.conf"
+    if [ -f "$file_path" ]; then
+      sed -i 's/autostart=false/autostart=true/g' "$file_path"
+    else
+      echo "$file_path not found"
+    fi
+done < "$AUTO_START"
+
+declare -A jmenv
+while IFS='=' read -r -d '' envkey parsedval; do
+    n="${envkey,,}" # lowercase
+    if [[ "$n" =  jm_* ]]; then
+        n="${n:3}" # drop jm_
+        jmenv[$n]=${!envkey} # reread environment variable - characters might have been dropped (e.g 'ending in =')
+    fi
+done < <(env -0)
+
+# adapt 'blockchain_source' is missing and we're in regtest mode
+if [ "${jmenv['network']}" == "regtest" ] && [ "${jmenv['blockchain_source']}" == "" ]; then
+    jmenv['blockchain_source']='regtest'
+fi
+
+# there is no 'regtest' value for config 'network': make sure to use "testnet" in regtest mode
+if [ "${jmenv['network']}" == "regtest" ]; then
+    jmenv['network']='testnet'
+fi
+
+# ---------- Hidden Service Directory
+# Avoid preventing user provided files: Copy the contents of the mounted directory to an own directory
+jmenv['hidden_service_dir']=${jmenv['hidden_service_dir']:-'\/root\/.joinmarket\/hidden_service_dir'}
+hsdirEscapedUnsafe="${jmenv['hidden_service_dir']}"
+hsdirEscapedSafe="${hsdirEscapedUnsafe}__copy"
+jmenv['hidden_service_dir']="${hsdirEscapedSafe}"
+
+hsdirUnsafe=$(sed "s/^.*/${hsdirEscapedUnsafe}/g" <<< '')
+hsdirSafe=$(sed "s/^.*/${hsdirEscapedSafe}/g" <<< '')
+
+mkdir -p "${hsdirUnsafe}"
+rm -rf "${hsdirSafe}"
+cp -R "${hsdirUnsafe}" "${hsdirSafe}"
+chown root:root -R "${hsdirSafe}"
+# this is important because tor is very finicky about permissions
+# see https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/68c64e135dabafca8ed78202ace1ced1884684be/docs/onion-message-channels.md#joinmarket-specific-configuration
+chmod 600 -R "${hsdirSafe}"
+# ---------- Hidden Service Directory - End
+
+# For every env variable JM_FOO=BAR, replace the default configuration value of 'foo' by 'BAR'
+for key in "${!jmenv[@]}"; do
+    val=${jmenv[${key}]}
+    sed -i "s/^$key =.*/$key = $val/g" "$CONFIG" || echo "Couldn't set : $key = $val, please modify $CONFIG manually"
+done
+
+
+exec supervisord
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/start-dn.py b/docker/regtest/dockerfile-deps/joinmarket/directory_node/start-dn.py
new file mode 100644
index 0000000..d19915a
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/start-dn.py
@@ -0,0 +1,108 @@
+#! /usr/bin/env python
+
+import sys
+from twisted.python.log import startLogging
+from optparse import OptionParser
+from jmbase import get_log, commands
+from jmclient import (Maker, jm_single, load_program_config,
+                      JMClientProtocolFactory, start_reactor,
+                      add_base_options, JMMakerClientProtocol,
+                      get_mchannels)
+import jmdaemon
+from jmbase.support import EXIT_ARGERROR, JM_APP_NAME, JM_CORE_VERSION
+
+jlog = get_log()
+
+# need to patch the Maker client protocol so that it doesn't
+# insist on a non-empty offer list:
+class DNMakerClientProtocol(JMMakerClientProtocol):
+
+    @commands.JMUp.responder
+    def on_JM_UP(self):
+        d = self.callRemote(commands.JMSetup,
+                            role="MAKER",
+                            initdata=self.client.offerlist,
+                            use_fidelity_bond=(self.client.fidelity_bond is not None))
+        self.defaultCallbacks(d)
+        return {'accepted': True}
+
+class DNJMClientProtocolFactory(JMClientProtocolFactory):
+    def __init__(self, client, proto_type="TAKER"):
+        self.client = client
+        self.proto_client = None
+        self.proto_type = proto_type
+        if self.proto_type == "MAKER":
+            self.protocol = DNMakerClientProtocol
+
+# Next we make a patch to the daemon-side so that orderbook
+# requests are ignored, otherwise our empty offer list will
+# causes crashes:
+def announce_no_orders(self, orderlist, nick, fidelity_bond_proof_msg, new_mc):
+    return
+jmdaemon.MessageChannelCollection.announce_orders = announce_no_orders
+
+# Now we create a super-dumbed down type of Maker,
+# with no offers and no wallet:
+class DNMaker(Maker):
+    def __init__(self):
+        # Note: we do not call the superclass init;
+        # nothing needs to be done, and there is no wallet.
+        # We set items that get referred to in client-daemon
+        # communication:
+        self.fidelity_bond = None
+        self.offerlist = []
+        self.aborted = False
+
+    # implementations of ABC methods
+    def create_my_orders(self):
+        return []
+
+    # none of the remainder can ever get called:
+    def oid_to_order(self, cjorder, amount):
+        pass
+
+    def on_tx_unconfirmed(self, cjorder, txid):
+        pass
+
+    def on_tx_confirmed(self, cjorder, txid, confirmations):
+        pass
+
+    def get_fidelity_bond_template(self):
+        return None
+
+def directory_node_startup():
+    parser = OptionParser(usage='usage: %prog [options]')
+    add_base_options(parser)
+    (options, args) = parser.parse_args()
+    # for string access, convert to dict:
+    options = vars(options)
+    if len(args) != 1:
+        parser.error('One argument required: string to be published in the MOTD of the directory node.')
+        sys.exit(EXIT_ARGERROR)
+    operator_message = args[0]
+    # It's possible to set `no-blockchain` in the config file, but this just
+    # makes it easier for the user:
+    load_program_config(config_path=options["datadir"], bs="no-blockchain")
+    # note: you *must* only have the onionmc, no IRC, for this to work,
+    # and of course you must only have your own d-node configured here:
+    mchan_config = get_mchannels()[0]
+    node_location = mchan_config["directory_nodes"]
+    # before starting, patch the server handshake default to include our MOTD customization
+    # default acceptance false; code must switch it on:
+    jmdaemon.onionmc.server_handshake_json[
+        "motd"] = "DIRECTORY NODE: {}\nJOINMARKET VERSION: {}\n{}".format(
+            node_location, JM_CORE_VERSION, operator_message)
+    maker = DNMaker()
+    jlog.info('starting directory node')
+    clientfactory = DNJMClientProtocolFactory(maker, proto_type="MAKER")
+    nodaemon = jm_single().config.getint("DAEMON", "no_daemon")
+    daemon = bool(nodaemon)
+    if jm_single().config.get("BLOCKCHAIN", "network") in ["regtest", "testnet", "signet"]:
+        startLogging(sys.stdout)
+    start_reactor(jm_single().config.get("DAEMON", "daemon_host"),
+                      jm_single().config.getint("DAEMON", "daemon_port"),
+                      clientfactory, daemon=daemon)
+
+if __name__ == "__main__":
+    directory_node_startup()
+
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/directory-node.conf b/docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/directory-node.conf
new file mode 100644
index 0000000..8fc6e54
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/directory-node.conf
@@ -0,0 +1,10 @@
+[program:directory-node]
+directory=/src/scripts
+command=python3 start-dn.py datadir=/root/.joinmarket
+autostart=false
+stdout_logfile=/root/.joinmarket/logs/directory_node_stdout.log
+stdout_logfile_maxbytes=5MB
+stdout_logfile_backups=0
+stderr_logfile=/root/.joinmarket/logs/directory_node_stderr.log
+stderr_logfile_maxbytes=5MB
+stderr_logfile_backups=0
diff --git a/docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/supervisord.conf b/docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/supervisord.conf
new file mode 100644
index 0000000..54296b8
--- /dev/null
+++ b/docker/regtest/dockerfile-deps/joinmarket/directory_node/supervisor-conf/supervisord.conf
@@ -0,0 +1,2 @@
+[supervisord]
+nodaemon=true
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile
index 27d3600..9ddc60f 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/Dockerfile
@@ -6,10 +6,11 @@ RUN apt-get update \
     && rm -rf /var/lib/apt/lists/*
 
 ENV REPO https://github.com/JoinMarket-Org/joinmarket-clientserver
+ENV REPO_BRANCH master
 ENV REPO_REF master
 
 WORKDIR /src
-RUN git clone "$REPO" . --depth=10 --branch "$REPO_REF" && git checkout "$REPO_REF"
+RUN git clone "$REPO" . --depth=10 --branch "$REPO_BRANCH" && git checkout "$REPO_REF"
 
 RUN ./install.sh --docker-install --disable-secp-check --without-qt
 
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/autostart b/docker/regtest/dockerfile-deps/joinmarket/latest/autostart
index 17c149a..4ca7a87 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/autostart
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/autostart
@@ -1,5 +1,5 @@
-# Remove comments in from of the service you want to automatically restart
-# when the container restart.
+# Remove comments in front of the services you want to automatically restart
+# when the container restarts.
 
 ob-watcher
 jmwalletd
diff --git a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
index 5ae4c86..1192461 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/latest/default.cfg
@@ -1,15 +1,18 @@
 [DAEMON]
-#set to 1 to run the daemon service within this process;
-#set to 0 if the daemon is run separately (using script joinmarketd.py)
+# set to 1 to run the daemon service within this process;
+# set to 0 if the daemon is run separately (using script joinmarketd.py)
 no_daemon = 1
-#port on which daemon serves; note that communication still
-#occurs over this port even if no_daemon = 1
+
+# Port on which daemon serves; note that communication still
+# occurs over this port even if no_daemon = 1
 daemon_port = 27183
-#currently, running the daemon on a remote host is
-#*NOT* supported, so don't change this variable
+
+# Currently, running the daemon on a remote host is
+# *NOT* supported, so don't change this variable
 daemon_host = localhost
-#by default the client-daemon connection is plaintext, set to 'true' to use TLS;
-#for this, you need to have a valid (self-signed) certificate installed
+
+# by default the client-daemon connection is plaintext, set to 'true' to use TLS;
+# for this, you need to have a valid (self-signed) certificate installed
 use_ssl = false
 
 [BLOCKCHAIN]
@@ -17,19 +20,74 @@ use_ssl = false
 # When using bitcoin-rpc-no-history remember to increase the gap limit to scan for more addresses, try -g 5000
 # Use 'no-blockchain' to run the ob-watcher.py script in scripts/obwatch without current access
 # to Bitcoin Core; note that use of this option for any other purpose is currently unsupported.
-#blockchain_source = bitcoin-rpc
 blockchain_source = regtest
+
 # options: signet, testnet, mainnet
 # Note: for regtest, use network = testnet
-#network = mainnet
 network = testnet
+
 rpc_host = localhost
 # default ports are 8332 for mainnet, 18443 for regtest, 18332 for testnet, 38332 for signet
-rpc_port = 8332
+rpc_port =
+
+# Use either rpc_user / rpc_password pair or rpc_cookie_file.
 rpc_user = bitcoin
 rpc_password = password
+#rpc_cookie_file =
+
+# rpc_wallet_file is Bitcoin Core wallet which is used for address and
+# transaction monitoring (it is watchonly, no private keys are stored there).
+# It must be created manually if does not exist, see docs/USAGE.md for more
+# information.
 rpc_wallet_file =
 
+[MESSAGING:onion]
+# onion based message channels must have the exact type 'onion'
+# (while the section name above can be MESSAGING:whatever), and there must
+# be only ONE such message channel configured (note the directory servers
+# can be multiple, below):
+type = onion
+
+socks5_host = localhost
+socks5_port = 9050
+
+# the tor control configuration.
+# for most people running the tor daemon
+# on Linux, no changes are required here:
+tor_control_host = localhost
+# or, to use a UNIX socket
+# tor_control_host = unix:/var/run/tor/control
+# note: port needs to be provided (but is ignored for UNIX socket)
+tor_control_port = 9051
+
+# the host/port actually serving the hidden service
+# (note the *virtual port*, that the client uses,
+# is hardcoded to as per below 'directory node configuration'.
+onion_serving_host = 127.0.0.1
+onion_serving_port = 8080
+
+# directory node configuration
+# (taken from joinmarket-clientserver/test/regtest_joinmarket.cfg)
+#
+# Special handling on regtest, so just ignore and let the code handle it:
+hidden_service_dir =
+#
+# This is a comma separated list (comma can be omitted if only one item).
+# Each item has format host:port ; both are required, though port will
+# be 5222 if created in this code.
+# for MAINNET:
+# directory_nodes = 3kxw6lf5vf6y26emzwgibzhrzhmhqiw6ekrek3nqfjjmhwznb2moonad.onion:5222,jmdirjmioywe2s5jad7ts6kgcqg66rj6wujj6q77n6wbdrgocqwexzid.onion:5222,bqlpq6ak24mwvuixixitift4yu42nxchlilrcqwk2ugn45tdclg42qid.onion:5222
+
+# for SIGNET (testing network):
+# directory_nodes = rr6f6qtleiiwic45bby4zwmiwjrj3jsbmcvutwpqxjziaydjydkk5iad.onion:5222,k74oyetjqgcamsyhlym2vgbjtvhcrbxr4iowd4nv4zk5sehw4v665jad.onion:5222,y2ruswmdbsfl4hhwwiqz4m3sx6si5fr6l3pf62d4pms2b53wmagq3eqd.onion:5222
+
+# for REGTEST (local dev setup):
+directory_nodes = localhost:8081
+
+# This setting is ONLY for developer regtest setups,
+# running multiple bots at once. Don't alter it otherwise
+regtest_count = 0,0
+
 [MESSAGING:server1]
 host = irc
 hostid = localhost1
@@ -40,17 +98,10 @@ socks5 = false
 socks5_host = localhost
 socks5_port = 9050
 
-#for tor
-#host = vxecvd6lc4giwtasjhgbrr3eop6pzq6i5rveracktioneunalgqlwfad.onion
-#port = 6667
-#usessl = false
-#socks5 = true
-
 [LOGGING]
 # Set the log level for the output to the terminal/console
 # Possible choices: DEBUG / INFO / WARNING / ERROR
 # Log level for the files in the logs-folder will always be DEBUG
-#console_log_level = INFO
 console_log_level = DEBUG
 
 # Use color-coded log messages to help distinguish log levels?:
@@ -78,27 +129,31 @@ native = true
 # but don't forget to bump your miner fees!
 merge_algorithm = default
 
-# The fee estimate is based on a projection of how many satoshis
-# per kB are needed to get in one of the next N blocks, N set here
-# as the value of 'tx_fees'. This cost estimate is high if you set
+# The fee estimate is based on a projection of how many sats/kilo-vbyte
+# are needed to get in one of the next N blocks. N is set here as
+# the value of 'tx_fees'. This cost estimate is high if you set
 # N=1, so we choose 3 for a more reasonable figure, as our default.
-# You can also set your own fee/kb: any number higher than 1000 will
-# be interpreted as the fee in satoshi per kB that you wish to use
-# example: N=30000 will use 30000 sat/kB as a fee, while N=5
-# will use the estimate from your selected blockchain source
+# You can also set your own fee/kilo-vbyte: any number higher than 1 thousand
+# will be interpreted as the fee in sats/kilo-vbyte that you wish to use.
+#
+# Example: N=30000 will use 30 thousand sats/kilo-vbyte (30 sats/vB) as a fee,
+# while N=5 will use the 5 block estimate from your selected blockchain source.
 tx_fees = 3
 
 # Transaction fee rate variance factor, 0.2 means 20% variation around
 # any manually chosen values, so if you set tx_fees=10000 and
-# tx_fees_factor=0.2, it might use any value between 8000 and 12000 for
-# your transactions.
+# tx_fees_factor=0.2, it might use any value between 8 thousand and 12 thousand
+# for your transactions.
 tx_fees_factor = 0.2
 
 # For users getting transaction fee estimates over an API,
-# place a sanity check limit on the satoshis-per-kB to be paid.
+# place a sanity check limit on the sats/kilo-vbyte to be paid.
 # This limit is also applied to users using Core, even though
 # Core has its own sanity check limit, which is currently
-# 1,000,000 satoshis.
+# 1 million satoshis.
+#
+# Example: N=350000 will use 350 thousand sats/kilo-vbyte (350 sats/vB) as a
+# maximum fee.
 absurd_fee_per_kb = 350000
 
 # In decimal, the maximum allowable change either lower or
@@ -109,7 +164,7 @@ absurd_fee_per_kb = 350000
 #
 # Example: max_sweep_fee_change = 0.4, with tx_fees = 10000,
 # means actual fee rate achieved in the sweep can be as low
-# as 6000 sats/kilo-vbyte up to 14000 sats/kilo-vbyte.
+# as 6 thousand sats/kilo-vbyte up to 14 thousand sats/kilo-vbyte.
 #
 # If this is not achieved, the transaction is aborted. For tumbler,
 # it will then be retried until successful.
@@ -126,19 +181,19 @@ max_sweep_fee_change = 0.8
 max_cj_fee_abs = 300000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
-# e.g. if your coinjoin amount is 2 btc (200000000 satoshi) and
+# 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 (200000 satoshi)
 
 # taken from jam production image
 max_cj_fee_rel = 0.0003
 
-# the range of confirmations passed to the `listunspent` bitcoind RPC call
+# The range of confirmations passed to the `listunspent` bitcoind RPC call
 # 1st value is the inclusive minimum, defaults to one confirmation
 # 2nd value is the exclusive maximum, defaults to most-positive-bignum (Google Me!)
 # leaving it unset or empty defers to bitcoind's default values, ie [1, 9999999]
 #listunspent_args = []
-# that's what you should do, unless you have a specific reason, eg:
+# That's what you should do, unless you have a specific reason, eg:
 #  !!! WARNING !!! CONFIGURING THIS WHILE TAKING LIQUIDITY FROM
 #  !!! WARNING !!! THE PUBLIC ORDERBOOK LEAKS YOUR INPUT MERGES
 #  spend from unconfirmed transactions:  listunspent_args = [0]
@@ -154,15 +209,15 @@ max_cj_fee_rel = 0.0003
 # self = broadcast transaction with your own bitcoin node.
 #
 # random-peer = everyone who took part in the coinjoin has a chance of broadcasting
-# note: if your counterparties do not support it, you will fall back
+# Note: if your counterparties do not support it, you will fall back
 # to broadcasting via your own node.
 #
 # not-self = never broadcast with your own bitcoin node.
-# note: in this case if your counterparties do not broadcast for you, you
+#
+# Note: in this case if your counterparties do not broadcast for you, you
 # will have to broadcast the tx manually (you can take the tx hex from the log
 # or terminal) via some other channel. It is not recommended to choose this
 # option when running schedules/tumbler.
-
 tx_broadcast = random-peer
 
 # If makers do not respond while creating a coinjoin transaction,
@@ -196,41 +251,52 @@ interest_rate = 0.015
 # A real number, i.e. 1 = 100%, 0.125 = 1/8 = 1 in every 8 makers on average will be bondless
 bondless_makers_allowance = 0.125
 
+# To (strongly) disincentivize Sybil behaviour, the value assessment of the bond
+# is based on the (time value of the bond)^x where x is the bond_value_exponent here,
+# where x > 1. It is a real number (so written as a decimal).
+bond_value_exponent = 1.3
+
 ##############################
-#THE FOLLOWING SETTINGS ARE REQUIRED TO DEFEND AGAINST SNOOPERS.
-#DON'T ALTER THEM UNLESS YOU UNDERSTAND THE IMPLICATIONS.
+# THE FOLLOWING SETTINGS ARE REQUIRED TO DEFEND AGAINST SNOOPERS.
+# DON'T ALTER THEM UNLESS YOU UNDERSTAND THE IMPLICATIONS.
 ##############################
 
-# number of retries allowed for a specific utxo, to prevent DOS/snooping.
+# Number of retries allowed for a specific utxo, to prevent DOS/snooping.
 # Lower settings make snooping more expensive, but also prevent honest users
 # from retrying if an error occurs.
 taker_utxo_retries = 3
 
-# number of confirmations required for the commitment utxo mentioned above.
+# Number of confirmations required for the commitment utxo mentioned above.
 # this effectively rate-limits a snooper.
 taker_utxo_age = 5
 
-# percentage of coinjoin amount that the commitment utxo must have
+# Percentage of coinjoin amount that the commitment utxo must have
 # as a minimum BTC amount. Thus 20 means a 1BTC coinjoin requires the
 # utxo to be at least 0.2 btc.
 taker_utxo_amtpercent = 20
 
-#Set to 1 to accept broadcast PoDLE commitments from other bots, and
-#add them to your blacklist (only relevant for Makers).
-#There is no way to spoof these values, so the only "risk" is that
-#someone fills your blacklist file with a lot of data.
+# Set to 1 to accept broadcast PoDLE commitments from other bots, and
+# add them to your blacklist (only relevant for Makers).
+# There is no way to spoof these values, so the only "risk" is that
+# someone fills your blacklist file with a lot of data.
 accept_commitment_broadcasts = 1
 
-#Location of your commitments.json file (stores commitments you've used
-#and those you want to use in future), relative to the scripts directory.
+# Location of your commitments.json file (stores commitments you've used
+# and those you want to use in future), relative to the scripts directory.
 commit_file_location = cmtdata/commitments.json
 
+# Location of the file used by makers to keep track of used/blacklisted
+# commitments. For remote daemon, set to `.` to have it stored locally
+# (but note that *all* bots using the same code installation share it,
+# in this case, which can be bad in testing).
+commitment_list_location = cmtdata/commitmentlist
+
 ##############################
 # END OF ANTI-SNOOPING SETTINGS
 ##############################
 
 [PAYJOIN]
-# for the majority of situations, the defaults
+# For the majority of situations, the defaults
 # need not be altered - they will ensure you don't pay
 # a significantly higher fee.
 # MODIFICATION OF THESE SETTINGS IS DISADVISED.
@@ -238,7 +304,7 @@ commit_file_location = cmtdata/commitments.json
 # Payjoin protocol version; currently only '1' is supported.
 payjoin_version = 1
 
-# servers can change their destination address by default (0).
+# Servers can change their destination address by default (0).
 # if '1', they cannot. Note that servers can explicitly request
 # that this is activated, in which case we respect that choice.
 disable_output_substitution = 0
@@ -254,21 +320,24 @@ disable_output_substitution = 0
 # to that of our change output, unless there is none in which case this is disabled.
 max_additional_fee_contribution = default
 
-# this is the minimum satoshis per vbyte we allow in the payjoin
+# This is the minimum sats/vbyte we allow in the payjoin
 # transaction; note it is decimal, not integer.
 min_fee_rate = 1.1
 
-# for payjoins as sender (i.e. client) to hidden service endpoints,
+# For payjoins as sender (i.e. client) to hidden service endpoints,
 # the socks5 configuration:
 onion_socks5_host = localhost
 onion_socks5_port = 9050
 
-# for payjoin onion service creation:
+# For payjoin onion service creation:
 # the tor control configuration:
 tor_control_host = localhost
+
 # or, to use a UNIX socket
 # control_host = unix:/var/run/tor/control
+# note: port needs to be provided (but is ignored for UNIX socket)
 tor_control_port = 9051
+
 # the host/port actually serving the hidden service
 # (note the *virtual port*, that the client uses,
 # is hardcoded to 80):
@@ -309,21 +378,20 @@ size_factor = 0.1
 gaplimit = 6
 
 [SNICKER]
-
-# any other value than 'true' will be treated as False,
+# Any other value than 'true' will be treated as False,
 # and no SNICKER actions will be enabled in that case:
 enabled = false
 
-# in satoshis, we require any SNICKER to pay us at least
+# In satoshis, we require any SNICKER to pay us at least
 # this much (can be negative), otherwise we will refuse
 # to sign it:
 lowest_net_gain = 0
 
-# comma separated list of servers (if port is omitted as :port, it
+# Comma separated list of servers (if port is omitted as :port, it
 # is assumed to be 80) which we will poll against (all, in sequence); note
 # that they are allowed to be *.onion or cleartext servers, and no
 # scheme (http(s) etc) needs to be added to the start.
 servers = cn5lfwvrswicuxn3gjsxoved6l2gu5hdvwy5l3ev7kg6j7lbji2k7hqd.onion,
 
-# how many minutes between each polling event to each server above:
+# How many minutes between each polling event to each server above:
 polling_interval_minutes = 60
diff --git a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
index 5ae4c86..1192461 100644
--- a/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
+++ b/docker/regtest/dockerfile-deps/joinmarket/webui-standalone/default.cfg
@@ -1,15 +1,18 @@
 [DAEMON]
-#set to 1 to run the daemon service within this process;
-#set to 0 if the daemon is run separately (using script joinmarketd.py)
+# set to 1 to run the daemon service within this process;
+# set to 0 if the daemon is run separately (using script joinmarketd.py)
 no_daemon = 1
-#port on which daemon serves; note that communication still
-#occurs over this port even if no_daemon = 1
+
+# Port on which daemon serves; note that communication still
+# occurs over this port even if no_daemon = 1
 daemon_port = 27183
-#currently, running the daemon on a remote host is
-#*NOT* supported, so don't change this variable
+
+# Currently, running the daemon on a remote host is
+# *NOT* supported, so don't change this variable
 daemon_host = localhost
-#by default the client-daemon connection is plaintext, set to 'true' to use TLS;
-#for this, you need to have a valid (self-signed) certificate installed
+
+# by default the client-daemon connection is plaintext, set to 'true' to use TLS;
+# for this, you need to have a valid (self-signed) certificate installed
 use_ssl = false
 
 [BLOCKCHAIN]
@@ -17,19 +20,74 @@ use_ssl = false
 # When using bitcoin-rpc-no-history remember to increase the gap limit to scan for more addresses, try -g 5000
 # Use 'no-blockchain' to run the ob-watcher.py script in scripts/obwatch without current access
 # to Bitcoin Core; note that use of this option for any other purpose is currently unsupported.
-#blockchain_source = bitcoin-rpc
 blockchain_source = regtest
+
 # options: signet, testnet, mainnet
 # Note: for regtest, use network = testnet
-#network = mainnet
 network = testnet
+
 rpc_host = localhost
 # default ports are 8332 for mainnet, 18443 for regtest, 18332 for testnet, 38332 for signet
-rpc_port = 8332
+rpc_port =
+
+# Use either rpc_user / rpc_password pair or rpc_cookie_file.
 rpc_user = bitcoin
 rpc_password = password
+#rpc_cookie_file =
+
+# rpc_wallet_file is Bitcoin Core wallet which is used for address and
+# transaction monitoring (it is watchonly, no private keys are stored there).
+# It must be created manually if does not exist, see docs/USAGE.md for more
+# information.
 rpc_wallet_file =
 
+[MESSAGING:onion]
+# onion based message channels must have the exact type 'onion'
+# (while the section name above can be MESSAGING:whatever), and there must
+# be only ONE such message channel configured (note the directory servers
+# can be multiple, below):
+type = onion
+
+socks5_host = localhost
+socks5_port = 9050
+
+# the tor control configuration.
+# for most people running the tor daemon
+# on Linux, no changes are required here:
+tor_control_host = localhost
+# or, to use a UNIX socket
+# tor_control_host = unix:/var/run/tor/control
+# note: port needs to be provided (but is ignored for UNIX socket)
+tor_control_port = 9051
+
+# the host/port actually serving the hidden service
+# (note the *virtual port*, that the client uses,
+# is hardcoded to as per below 'directory node configuration'.
+onion_serving_host = 127.0.0.1
+onion_serving_port = 8080
+
+# directory node configuration
+# (taken from joinmarket-clientserver/test/regtest_joinmarket.cfg)
+#
+# Special handling on regtest, so just ignore and let the code handle it:
+hidden_service_dir =
+#
+# This is a comma separated list (comma can be omitted if only one item).
+# Each item has format host:port ; both are required, though port will
+# be 5222 if created in this code.
+# for MAINNET:
+# directory_nodes = 3kxw6lf5vf6y26emzwgibzhrzhmhqiw6ekrek3nqfjjmhwznb2moonad.onion:5222,jmdirjmioywe2s5jad7ts6kgcqg66rj6wujj6q77n6wbdrgocqwexzid.onion:5222,bqlpq6ak24mwvuixixitift4yu42nxchlilrcqwk2ugn45tdclg42qid.onion:5222
+
+# for SIGNET (testing network):
+# directory_nodes = rr6f6qtleiiwic45bby4zwmiwjrj3jsbmcvutwpqxjziaydjydkk5iad.onion:5222,k74oyetjqgcamsyhlym2vgbjtvhcrbxr4iowd4nv4zk5sehw4v665jad.onion:5222,y2ruswmdbsfl4hhwwiqz4m3sx6si5fr6l3pf62d4pms2b53wmagq3eqd.onion:5222
+
+# for REGTEST (local dev setup):
+directory_nodes = localhost:8081
+
+# This setting is ONLY for developer regtest setups,
+# running multiple bots at once. Don't alter it otherwise
+regtest_count = 0,0
+
 [MESSAGING:server1]
 host = irc
 hostid = localhost1
@@ -40,17 +98,10 @@ socks5 = false
 socks5_host = localhost
 socks5_port = 9050
 
-#for tor
-#host = vxecvd6lc4giwtasjhgbrr3eop6pzq6i5rveracktioneunalgqlwfad.onion
-#port = 6667
-#usessl = false
-#socks5 = true
-
 [LOGGING]
 # Set the log level for the output to the terminal/console
 # Possible choices: DEBUG / INFO / WARNING / ERROR
 # Log level for the files in the logs-folder will always be DEBUG
-#console_log_level = INFO
 console_log_level = DEBUG
 
 # Use color-coded log messages to help distinguish log levels?:
@@ -78,27 +129,31 @@ native = true
 # but don't forget to bump your miner fees!
 merge_algorithm = default
 
-# The fee estimate is based on a projection of how many satoshis
-# per kB are needed to get in one of the next N blocks, N set here
-# as the value of 'tx_fees'. This cost estimate is high if you set
+# The fee estimate is based on a projection of how many sats/kilo-vbyte
+# are needed to get in one of the next N blocks. N is set here as
+# the value of 'tx_fees'. This cost estimate is high if you set
 # N=1, so we choose 3 for a more reasonable figure, as our default.
-# You can also set your own fee/kb: any number higher than 1000 will
-# be interpreted as the fee in satoshi per kB that you wish to use
-# example: N=30000 will use 30000 sat/kB as a fee, while N=5
-# will use the estimate from your selected blockchain source
+# You can also set your own fee/kilo-vbyte: any number higher than 1 thousand
+# will be interpreted as the fee in sats/kilo-vbyte that you wish to use.
+#
+# Example: N=30000 will use 30 thousand sats/kilo-vbyte (30 sats/vB) as a fee,
+# while N=5 will use the 5 block estimate from your selected blockchain source.
 tx_fees = 3
 
 # Transaction fee rate variance factor, 0.2 means 20% variation around
 # any manually chosen values, so if you set tx_fees=10000 and
-# tx_fees_factor=0.2, it might use any value between 8000 and 12000 for
-# your transactions.
+# tx_fees_factor=0.2, it might use any value between 8 thousand and 12 thousand
+# for your transactions.
 tx_fees_factor = 0.2
 
 # For users getting transaction fee estimates over an API,
-# place a sanity check limit on the satoshis-per-kB to be paid.
+# place a sanity check limit on the sats/kilo-vbyte to be paid.
 # This limit is also applied to users using Core, even though
 # Core has its own sanity check limit, which is currently
-# 1,000,000 satoshis.
+# 1 million satoshis.
+#
+# Example: N=350000 will use 350 thousand sats/kilo-vbyte (350 sats/vB) as a
+# maximum fee.
 absurd_fee_per_kb = 350000
 
 # In decimal, the maximum allowable change either lower or
@@ -109,7 +164,7 @@ absurd_fee_per_kb = 350000
 #
 # Example: max_sweep_fee_change = 0.4, with tx_fees = 10000,
 # means actual fee rate achieved in the sweep can be as low
-# as 6000 sats/kilo-vbyte up to 14000 sats/kilo-vbyte.
+# as 6 thousand sats/kilo-vbyte up to 14 thousand sats/kilo-vbyte.
 #
 # If this is not achieved, the transaction is aborted. For tumbler,
 # it will then be retried until successful.
@@ -126,19 +181,19 @@ max_sweep_fee_change = 0.8
 max_cj_fee_abs = 300000
 
 # Maximum relative coinjoin fee, in fractions of the coinjoin value
-# e.g. if your coinjoin amount is 2 btc (200000000 satoshi) and
+# 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 (200000 satoshi)
 
 # taken from jam production image
 max_cj_fee_rel = 0.0003
 
-# the range of confirmations passed to the `listunspent` bitcoind RPC call
+# The range of confirmations passed to the `listunspent` bitcoind RPC call
 # 1st value is the inclusive minimum, defaults to one confirmation
 # 2nd value is the exclusive maximum, defaults to most-positive-bignum (Google Me!)
 # leaving it unset or empty defers to bitcoind's default values, ie [1, 9999999]
 #listunspent_args = []
-# that's what you should do, unless you have a specific reason, eg:
+# That's what you should do, unless you have a specific reason, eg:
 #  !!! WARNING !!! CONFIGURING THIS WHILE TAKING LIQUIDITY FROM
 #  !!! WARNING !!! THE PUBLIC ORDERBOOK LEAKS YOUR INPUT MERGES
 #  spend from unconfirmed transactions:  listunspent_args = [0]
@@ -154,15 +209,15 @@ max_cj_fee_rel = 0.0003
 # self = broadcast transaction with your own bitcoin node.
 #
 # random-peer = everyone who took part in the coinjoin has a chance of broadcasting
-# note: if your counterparties do not support it, you will fall back
+# Note: if your counterparties do not support it, you will fall back
 # to broadcasting via your own node.
 #
 # not-self = never broadcast with your own bitcoin node.
-# note: in this case if your counterparties do not broadcast for you, you
+#
+# Note: in this case if your counterparties do not broadcast for you, you
 # will have to broadcast the tx manually (you can take the tx hex from the log
 # or terminal) via some other channel. It is not recommended to choose this
 # option when running schedules/tumbler.
-
 tx_broadcast = random-peer
 
 # If makers do not respond while creating a coinjoin transaction,
@@ -196,41 +251,52 @@ interest_rate = 0.015
 # A real number, i.e. 1 = 100%, 0.125 = 1/8 = 1 in every 8 makers on average will be bondless
 bondless_makers_allowance = 0.125
 
+# To (strongly) disincentivize Sybil behaviour, the value assessment of the bond
+# is based on the (time value of the bond)^x where x is the bond_value_exponent here,
+# where x > 1. It is a real number (so written as a decimal).
+bond_value_exponent = 1.3
+
 ##############################
-#THE FOLLOWING SETTINGS ARE REQUIRED TO DEFEND AGAINST SNOOPERS.
-#DON'T ALTER THEM UNLESS YOU UNDERSTAND THE IMPLICATIONS.
+# THE FOLLOWING SETTINGS ARE REQUIRED TO DEFEND AGAINST SNOOPERS.
+# DON'T ALTER THEM UNLESS YOU UNDERSTAND THE IMPLICATIONS.
 ##############################
 
-# number of retries allowed for a specific utxo, to prevent DOS/snooping.
+# Number of retries allowed for a specific utxo, to prevent DOS/snooping.
 # Lower settings make snooping more expensive, but also prevent honest users
 # from retrying if an error occurs.
 taker_utxo_retries = 3
 
-# number of confirmations required for the commitment utxo mentioned above.
+# Number of confirmations required for the commitment utxo mentioned above.
 # this effectively rate-limits a snooper.
 taker_utxo_age = 5
 
-# percentage of coinjoin amount that the commitment utxo must have
+# Percentage of coinjoin amount that the commitment utxo must have
 # as a minimum BTC amount. Thus 20 means a 1BTC coinjoin requires the
 # utxo to be at least 0.2 btc.
 taker_utxo_amtpercent = 20
 
-#Set to 1 to accept broadcast PoDLE commitments from other bots, and
-#add them to your blacklist (only relevant for Makers).
-#There is no way to spoof these values, so the only "risk" is that
-#someone fills your blacklist file with a lot of data.
+# Set to 1 to accept broadcast PoDLE commitments from other bots, and
+# add them to your blacklist (only relevant for Makers).
+# There is no way to spoof these values, so the only "risk" is that
+# someone fills your blacklist file with a lot of data.
 accept_commitment_broadcasts = 1
 
-#Location of your commitments.json file (stores commitments you've used
-#and those you want to use in future), relative to the scripts directory.
+# Location of your commitments.json file (stores commitments you've used
+# and those you want to use in future), relative to the scripts directory.
 commit_file_location = cmtdata/commitments.json
 
+# Location of the file used by makers to keep track of used/blacklisted
+# commitments. For remote daemon, set to `.` to have it stored locally
+# (but note that *all* bots using the same code installation share it,
+# in this case, which can be bad in testing).
+commitment_list_location = cmtdata/commitmentlist
+
 ##############################
 # END OF ANTI-SNOOPING SETTINGS
 ##############################
 
 [PAYJOIN]
-# for the majority of situations, the defaults
+# For the majority of situations, the defaults
 # need not be altered - they will ensure you don't pay
 # a significantly higher fee.
 # MODIFICATION OF THESE SETTINGS IS DISADVISED.
@@ -238,7 +304,7 @@ commit_file_location = cmtdata/commitments.json
 # Payjoin protocol version; currently only '1' is supported.
 payjoin_version = 1
 
-# servers can change their destination address by default (0).
+# Servers can change their destination address by default (0).
 # if '1', they cannot. Note that servers can explicitly request
 # that this is activated, in which case we respect that choice.
 disable_output_substitution = 0
@@ -254,21 +320,24 @@ disable_output_substitution = 0
 # to that of our change output, unless there is none in which case this is disabled.
 max_additional_fee_contribution = default
 
-# this is the minimum satoshis per vbyte we allow in the payjoin
+# This is the minimum sats/vbyte we allow in the payjoin
 # transaction; note it is decimal, not integer.
 min_fee_rate = 1.1
 
-# for payjoins as sender (i.e. client) to hidden service endpoints,
+# For payjoins as sender (i.e. client) to hidden service endpoints,
 # the socks5 configuration:
 onion_socks5_host = localhost
 onion_socks5_port = 9050
 
-# for payjoin onion service creation:
+# For payjoin onion service creation:
 # the tor control configuration:
 tor_control_host = localhost
+
 # or, to use a UNIX socket
 # control_host = unix:/var/run/tor/control
+# note: port needs to be provided (but is ignored for UNIX socket)
 tor_control_port = 9051
+
 # the host/port actually serving the hidden service
 # (note the *virtual port*, that the client uses,
 # is hardcoded to 80):
@@ -309,21 +378,20 @@ size_factor = 0.1
 gaplimit = 6
 
 [SNICKER]
-
-# any other value than 'true' will be treated as False,
+# Any other value than 'true' will be treated as False,
 # and no SNICKER actions will be enabled in that case:
 enabled = false
 
-# in satoshis, we require any SNICKER to pay us at least
+# In satoshis, we require any SNICKER to pay us at least
 # this much (can be negative), otherwise we will refuse
 # to sign it:
 lowest_net_gain = 0
 
-# comma separated list of servers (if port is omitted as :port, it
+# Comma separated list of servers (if port is omitted as :port, it
 # is assumed to be 80) which we will poll against (all, in sequence); note
 # that they are allowed to be *.onion or cleartext servers, and no
 # scheme (http(s) etc) needs to be added to the start.
 servers = cn5lfwvrswicuxn3gjsxoved6l2gu5hdvwy5l3ev7kg6j7lbji2k7hqd.onion,
 
-# how many minutes between each polling event to each server above:
+# How many minutes between each polling event to each server above:
 polling_interval_minutes = 60
diff --git a/docker/regtest/generate-onion-address.sh b/docker/regtest/generate-onion-address.sh
new file mode 100755
index 0000000..61904a5
--- /dev/null
+++ b/docker/regtest/generate-onion-address.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+
+###
+#
+# This script will generate keys and hostname to be used in a tor hidden
+# service setup. The output files are placed in a given target directory
+# or in a subdirectory of the current working directory if not specified.
+#
+###
+
+set -Eeuo pipefail
+trap cleanup SIGINT SIGTERM ERR EXIT
+
+cleanup() {
+  trap - SIGINT SIGTERM ERR EXIT
+  # script cleanup here
+}
+
+if ! command -v git &> /dev/null; then
+    die "This script needs 'git' to run. Consider installing it."
+fi
+if ! command -v docker &> /dev/null; then
+    die "This script needs 'docker' to run. Consider installing it."
+fi
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
+
+TARGETDIR="${1:-"${PWD}/generate-onion-address/out"}"
+WORKDIR="${2:-"${PWD}/generate-onion-address/work"}"
+VANITYTORGEN_REPO_DIR="${WORKDIR}/vanitytorgen"
+# TODO: fork of "https://github.com/Kexkey/vanitytorgen" - should be forked by jam org?
+VANITYTORGEN_REPO_URL="https://github.com/theborakompanioni/vanitytorgen"
+VANITYTORGEN_REPO_BRANCH="main"
+VANITYTORGEN_REPO_REF="85fa0c36208975f4e22bf10fb77b3d9bafb51979"
+
+# onion addresses are base32 - base32 alphabet allows letters [a-z] and digits [2-7]
+PREFIX_CHARS="234567abcdefghijklmnopqrstuvwxyz" # "jam"
+
+# choose a single random char
+RANDOM_PREFIX_CHAR_INDEX=$(($RANDOM % ${#PREFIX_CHARS}))
+ONION_ADDRESS_PREFIX="${PREFIX_CHARS:$RANDOM_PREFIX_CHAR_INDEX:1}"
+
+echo "Will use prefix: ${ONION_ADDRESS_PREFIX}"
+
+mkdir -p "$TARGETDIR"
+mkdir -p "$WORKDIR"
+
+# download "vanitygen" repo if necessary
+if ! [ -d "${VANITYTORGEN_REPO_DIR}" ]; then
+    git clone "${VANITYTORGEN_REPO_URL}" "${VANITYTORGEN_REPO_DIR}" --branch "${VANITYTORGEN_REPO_BRANCH}" \
+        && git --work-tree="${VANITYTORGEN_REPO_DIR}" --git-dir="${VANITYTORGEN_REPO_DIR}/.git" checkout "$VANITYTORGEN_REPO_REF" \
+        && rm -rf "${VANITYTORGEN_REPO_DIR}/.git"
+fi
+
+# build and run "vanitygen" docker container
+docker build --tag jam_regtest_vanitytorgen "${VANITYTORGEN_REPO_DIR}"
+docker run --rm --volume "$TARGETDIR:/out:z" jam_regtest_vanitytorgen "${ONION_ADDRESS_PREFIX}" /out
diff --git a/docker/regtest/prepare-setup.sh b/docker/regtest/prepare-setup.sh
new file mode 100755
index 0000000..1074685
--- /dev/null
+++ b/docker/regtest/prepare-setup.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+###
+#
+# This script prepares the regtest environment.
+#
+# The output of this script is a ".env.generated" file
+# to be used in when running docker-compose.
+# e.g. 
+# ```
+# docker-compose --env-file .env.generated --file docker-compose.yml up
+# ```
+#
+###
+
+set -Eeuo pipefail
+
+die() {
+  local code=${2-1} # default exit status 1
+  echo >&2 -e "${1-}"
+  exit "$code"
+}
+
+if ! command -v cat &> /dev/null; then
+    die "This script needs 'cat' to run. Consider installing it."
+fi
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
+
+OUTPUT_FILE="$SCRIPT_DIR/.env.generated"
+
+# generate an onion address
+HS_SCRIPT_TARGET_DIR="${SCRIPT_DIR}/out/hidden_service_dir"
+HS_SCRIPT_WORK_DIR="${SCRIPT_DIR}/.tmp/generate-onion-address-work"
+. "$SCRIPT_DIR/generate-onion-address.sh" "${HS_SCRIPT_TARGET_DIR}" "${HS_SCRIPT_WORK_DIR}"
+
+
+ONION_ADDRESS=`cat ${HS_SCRIPT_TARGET_DIR}/hostname`
+
+if ! [[ "${ONION_ADDRESS}" == *.onion ]]; then
+  die "Invalid argument: Could not find onion address in ${HS_SCRIPT_TARGET_DIR}/hostname"
+fi
+
+ONION_ADDRESS_WITH_PORT="${ONION_ADDRESS}:5222"
+
+cat <<EOF > "${OUTPUT_FILE}"
+JM_DIRECTORY_NODES=${ONION_ADDRESS_WITH_PORT}
+
+EOF
+
+echo "Successfully written to ${OUTPUT_FILE}"
diff --git a/src/components/Balance.test.jsx b/src/components/Balance.test.jsx
index 551c499..8c62461 100644
--- a/src/components/Balance.test.jsx
+++ b/src/components/Balance.test.jsx
@@ -1,4 +1,6 @@
 import React from 'react'
+import { act } from 'react-dom/test-utils'
+import user from '@testing-library/user-event'
 import { render, screen } from '../testUtils'
 import { BTC, SATS } from '../utils'
 
@@ -116,4 +118,70 @@ describe('<Balance />', () => {
     render(<Balance valueString={43000} convertToUnit={SATS} showBalance={true} />)
     expect(screen.getByText(`43000`)).toBeInTheDocument()
   })
+
+  it('should toggle visibility of initially hidden balance on click by default', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} />)
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`*****`))
+    })
+
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`21`))
+    })
+
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+  })
+
+  it('should NOT toggle visibility of initially hidden balance on click when disabled via flag', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={false} enableVisibilityToggle={false} />)
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`*****`))
+    })
+
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+  })
+
+  it('should NOT toggle visibility of initially visible balance on click by default', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} />)
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`21`))
+    })
+
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+  })
+
+  it('should toggle visibility of initially visible balance on click when enabled via flag', () => {
+    render(<Balance valueString={`21`} convertToUnit={SATS} showBalance={true} enableVisibilityToggle={true} />)
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`21`))
+    })
+
+    expect(screen.queryByText(`21`)).not.toBeInTheDocument()
+    expect(screen.getByText(`*****`)).toBeInTheDocument()
+
+    act(() => {
+      user.click(screen.getByText(`*****`))
+    })
+
+    expect(screen.getByText(`21`)).toBeInTheDocument()
+    expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
+  })
 })