Repository: joinmarket-webui/joinmarket-webui
Pull request #351 (2022-06-27 18:46:07)

refactor: move lock/unlock requests from `Wallet` to `Wallets`
Refactoring for easier testing and in preparation of https://github.com/joinmarket-webui/joinmarket-webui/pull/334, enables reacting to "lock/unlock" attempts in `Wallets` component.  

Instead of passing `startWallet` and `stopWallet` to the `Wallet` component, this will perform the API requests in the parent `Wallets` component and just provide `unlockWallet` and `lockWallet` callbacks to the child.
This in turn greatly simplifies the `Wallet` component: Instead of passing multiple properties to determine whether "lock" and "unlock" buttons should be shown, this decision is made by the caller. 

No functionality has been added or removed.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1d868b..d3cffab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+### [0.0.8](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.7...v0.0.8) (2022-06-28)
+
+#### Added
+
+* **jars:** add receive shortcut if jar 0 is empty ([#344](https://github.com/joinmarket-webui/joinmarket-webui/issues/344)) ([01afb88](https://github.com/joinmarket-webui/joinmarket-webui/commit/01afb88888c173b677f4979c5ae8eef350a9da19))
+* **jars:** destination jar selector on receive screen ([#346](https://github.com/joinmarket-webui/joinmarket-webui/issues/346)) ([911177c](https://github.com/joinmarket-webui/joinmarket-webui/commit/911177c899fe43fe2d889718dc644c6630629d8f))
+* **jars:** destination jar selector on send screen ([#345](https://github.com/joinmarket-webui/joinmarket-webui/issues/345)) ([a08c584](https://github.com/joinmarket-webui/joinmarket-webui/commit/a08c584f5ee51fd88e5b16c862626717955c7102))
+
+#### Fixed
+
+* Check preconditions before send request ([#349](https://github.com/joinmarket-webui/joinmarket-webui/issues/349)) ([581184d](https://github.com/joinmarket-webui/joinmarket-webui/commit/581184d92fa4bab1054d1d6e34d7777abebe7f00))
+* checked state of ToggleSwitch can be controlled by caller ([#332](https://github.com/joinmarket-webui/joinmarket-webui/issues/332)) ([c9007f5](https://github.com/joinmarket-webui/joinmarket-webui/commit/c9007f5428ae4ba79d68cd39972565a1747120e1))
+* remove fidelity bond feature flag ([9fe84c8](https://github.com/joinmarket-webui/joinmarket-webui/commit/9fe84c8cda8e3959d13fa436ea9f650bf1d0b3ed))
+
 ### [0.0.7](https://github.com/joinmarket-webui/joinmarket-webui/compare/v0.0.6...v0.0.7) (2022-06-20)
 
 #### Fixed
diff --git a/package-lock.json b/package-lock.json
index b8fdde3..38bb72e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "jam",
-  "version": "0.0.7",
+  "version": "0.0.8",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "jam",
-      "version": "0.0.7",
+      "version": "0.0.8",
       "hasInstallScript": true,
       "license": "MIT",
       "dependencies": {
diff --git a/package.json b/package.json
index 0d938af..395a48c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "jam",
-  "version": "0.0.7",
+  "version": "0.0.8",
   "private": true,
   "description": "A friendly UI for JoinMarket",
   "repository": "git@github.com:joinmarket-webui/joinmarket-webui.git",
diff --git a/src/components/App.jsx b/src/components/App.jsx
index b09f31f..4877d19 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -1,8 +1,7 @@
-import React, { useState, useEffect, useCallback } from 'react'
+import React, { useState, useEffect, useCallback, useMemo } from 'react'
 import { Route, Routes, Navigate } from 'react-router-dom'
 import * as rb from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
-import { isFeatureEnabled } from '../constants/features'
 import Wallets from './Wallets'
 import CreateWallet from './CreateWallet'
 import Jam from './Jam'
@@ -39,7 +38,7 @@ export default function App() {
   const [showBetaWarning, setShowBetaWarning] = useState(false)
   const [showCheatsheet, setShowCheatsheet] = useState(false)
 
-  const cheatsheetEnabled = currentWallet
+  const cheatsheetEnabled = useMemo(() => !!currentWallet, [currentWallet])
 
   const startWallet = useCallback(
     (name, token) => {
@@ -123,7 +122,10 @@ export default function App() {
           {!sessionConnectionError && (
             <>
               <Route element={<Layout />}>
-                <Route path={routes.home} element={<Wallets startWallet={startWallet} stopWallet={stopWallet} />} />
+                <Route
+                  path={routes.home}
+                  element={<Wallets currentWallet={currentWallet} startWallet={startWallet} stopWallet={stopWallet} />}
+                />
                 {currentWallet && (
                   <>
                     <Route path={routes.jam} element={<Jam />} />
@@ -131,9 +133,7 @@ export default function App() {
                     <Route path={routes.earn} element={<Earn />} />
                     <Route path={routes.receive} element={<Receive />} />
                     <Route path={routes.settings} element={<Settings stopWallet={stopWallet} />} />
-                    {isFeatureEnabled('fidelityBonds') && (
-                      <Route path={routes.fidelityBonds} element={<FidelityBond />} />
-                    )}
+                    <Route path={routes.fidelityBonds} element={<FidelityBond />} />
                   </>
                 )}
               </Route>
diff --git a/src/components/Cheatsheet.tsx b/src/components/Cheatsheet.tsx
index e378545..021c997 100644
--- a/src/components/Cheatsheet.tsx
+++ b/src/components/Cheatsheet.tsx
@@ -4,7 +4,6 @@ import { Link } from 'react-router-dom'
 import { Trans, useTranslation } from 'react-i18next'
 import { routes } from '../constants/routes'
 import Sprite from './Sprite'
-import { isFeatureEnabled } from '../constants/features'
 import styles from './Cheatsheet.module.css'
 
 interface CheatsheetProps {
@@ -42,7 +41,6 @@ function ListItem({ number, children, ...props }: PropsWithChildren<NumberedProp
 
 export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
   const { t } = useTranslation()
-  const featureFidelityBondsEnabled = isFeatureEnabled('fidelityBonds')
 
   return (
     <rb.Offcanvas className={styles.cheatsheet} show={show} onHide={onHide} placement="bottom" onClick={onHide}>
@@ -97,23 +95,13 @@ export default function Cheatsheet({ show = false, onHide }: CheatsheetProps) {
             </h6>
             <div className="small text-secondary">{t('cheatsheet.item_2.description')}</div>
           </ListItem>
-          <ListItem number={3} className={featureFidelityBondsEnabled ? '' : styles['upcoming-feature']}>
+          <ListItem number={3}>
             <h6>
               <Trans i18nKey="cheatsheet.item_3.title">
-                Optional: <Link to={featureFidelityBondsEnabled ? routes.fidelityBonds : routes.home}>Lock</Link> funds
-                in a fidelity bond.
+                Optional: <Link to={routes.fidelityBonds}>Lock</Link> funds in a fidelity bond.
               </Trans>
             </h6>
-            <div className="small text-secondary">
-              {t('cheatsheet.item_3.description')}
-              {/* the following phrase is intentionally not translated because it will be removed soon */}
-              {!featureFidelityBondsEnabled && (
-                <>
-                  <br />
-                  <strong>Feature not implemented yet. Coming soon!</strong>
-                </>
-              )}
-            </div>
+            <div className="small text-secondary">{t('cheatsheet.item_3.description')}</div>
           </ListItem>
           <ListItem number={4}>
             <h6>
diff --git a/src/components/CurrentWalletAdvanced.tsx b/src/components/CurrentWalletAdvanced.tsx
index f3aef39..8a2e3e0 100644
--- a/src/components/CurrentWalletAdvanced.tsx
+++ b/src/components/CurrentWalletAdvanced.tsx
@@ -10,7 +10,6 @@ import DisplayAccountUTXOs from './DisplayAccountUTXOs'
 import DisplayUTXOs from './DisplayUTXOs'
 // @ts-ignore
 import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
-import { isFeatureEnabled } from '../constants/features'
 import { routes } from '../constants/routes'
 import styles from './CurrentWalletAdvanced.module.css'
 
@@ -18,8 +17,6 @@ type Utxos = any[]
 type Alert = { message: string; variant: string }
 
 export default function CurrentWalletAdvanced() {
-  const featureFidelityBondsEnabled = isFeatureEnabled('fidelityBonds')
-
   const { t } = useTranslation()
   const currentWallet = useCurrentWallet()
   const walletInfo = useCurrentWalletInfo()
@@ -100,16 +97,14 @@ export default function CurrentWalletAdvanced() {
                   <Trans i18nKey="fidelity_bond.alert_no_fidelity_bonds" as="span">
                     No Fidelity Bond present.
                   </Trans>
-                  {featureFidelityBondsEnabled && (
-                    <>
-                      {' '}
-                      <Link to={routes.fidelityBonds}>
-                        <Trans i18nKey="current_wallet_advanced.link_fidelity_bonds_create_text" as="span">
-                          Create a Fidelity Bond.
-                        </Trans>
-                      </Link>
-                    </>
-                  )}
+                  <>
+                    {' '}
+                    <Link to={routes.fidelityBonds}>
+                      <Trans i18nKey="current_wallet_advanced.link_fidelity_bonds_create_text" as="span">
+                        Create a Fidelity Bond.
+                      </Trans>
+                    </Link>
+                  </>
                 </>
               </rb.Alert>
             ) : (
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index e5660a2..10e017e 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -7,13 +7,15 @@ import { useSettings } from '../context/SettingsContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { isDebugFeatureEnabled } from '../constants/debugFeatures'
-import styles from './Jam.module.css'
+import { useBalanceSummary } from '../hooks/BalanceSummary'
+import { COINJOIN_PRECONDITIONS, useCoinjoinPreconditionSummary } from '../hooks/CoinjoinPrecondition'
 import PageTitle from './PageTitle'
 import ToggleSwitch from './ToggleSwitch'
 import Sprite from './Sprite'
 import Balance from './Balance'
 import ScheduleProgress from './ScheduleProgress'
-import { isLocked, useBalanceSummary } from '../hooks/BalanceSummary'
+
+import styles from './Jam.module.css'
 
 // When running the scheduler with internal destination addresses, the funds
 // will end up on those 3 mixdepths (one UTXO each).
@@ -24,12 +26,6 @@ const SCHEDULE_REQUEST_INTERVAL = process.env.NODE_ENV === 'development' ? 10_00
 const SCHEDULER_STOP_RESPONSE_DELAY_MS = 2_000
 
 const SCHEDULER_START_ACCOUNT = 0
-const SCHEDULER_PRECONDITIONS = {
-  MIN_NUMBER_OF_UTXOS: 1, // min amount of utxos available
-  // https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.6/docs/SOURCING-COMMITMENTS.md#wait-for-at-least-5-confirmations
-  MIN_CONFIRMATIONS_OF_SINGLE_UTXO: 5, // at least one utxo needs X confirmations
-  MIN_OVERALL_REMAINING_RETRIES: 1, // amount of overall retries available
-}
 
 const ValuesListener = ({ handler }) => {
   const { values } = useFormikContext()
@@ -43,61 +39,6 @@ const ValuesListener = ({ handler }) => {
   return null
 }
 
-const DEFAULT_PRECONDITION_SUMMARY = {
-  isFulfilled: false,
-  numberOfMissingUtxos: SCHEDULER_PRECONDITIONS.MIN_NUMBER_OF_UTXOS,
-  amountOfMissingConfirmations: SCHEDULER_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
-  amountOfMissingOverallRetries: SCHEDULER_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES,
-}
-
-const useSchedulerPreconditionSummary = (walletInfoOrNull, startAccountIndex) => {
-  const eligibleUtxos = useMemo(() => {
-    if (!walletInfoOrNull) return []
-
-    const utxos = walletInfoOrNull.data.utxos.utxos || []
-    return utxos
-      .filter((it) => it.mixdepth === startAccountIndex)
-      .filter((it) => !it.frozen)
-      .filter((it) => !isLocked(it))
-  }, [walletInfoOrNull, startAccountIndex])
-
-  const [summary, setSummary] = useState(DEFAULT_PRECONDITION_SUMMARY)
-
-  useEffect(() => {
-    if (!eligibleUtxos) {
-      setSummary(DEFAULT_PRECONDITION_SUMMARY)
-      return
-    }
-
-    const numberOfMissingUtxos = Math.max(0, SCHEDULER_PRECONDITIONS.MIN_NUMBER_OF_UTXOS - eligibleUtxos.length)
-
-    const overallRetriesRemaining = eligibleUtxos.reduce((acc, utxo) => acc + utxo.tries_remaining, 0)
-    const amountOfMissingOverallRetries = Math.max(
-      0,
-      SCHEDULER_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES - overallRetriesRemaining
-    )
-
-    const maxConfirmations =
-      eligibleUtxos.length === 0 ? 0 : eligibleUtxos.reduce((acc, utxo) => Math.max(acc, utxo.confirmations), 0)
-    const amountOfMissingConfirmations = Math.max(
-      0,
-      SCHEDULER_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO - maxConfirmations
-    )
-
-    const isFulfilled =
-      numberOfMissingUtxos === 0 && amountOfMissingOverallRetries === 0 && amountOfMissingConfirmations === 0
-
-    setSummary({
-      isFulfilled,
-      numberOfMissingUtxos,
-      amountOfMissingConfirmations,
-      amountOfMissingOverallRetries,
-    })
-  }, [eligibleUtxos])
-
-  return summary
-}
-
 export default function Jam() {
   const { t } = useTranslation()
   const settings = useSettings()
@@ -114,7 +55,13 @@ export default function Jam() {
   const [collaborativeOperationRunning, setCollaborativeOperationRunning] = useState(false)
   const [schedule, setSchedule] = useState(null)
 
-  const schedulerPreconditionSummary = useSchedulerPreconditionSummary(walletInfo, SCHEDULER_START_ACCOUNT)
+  const startJarUtxos = useMemo(() => {
+    if (!walletInfo) return null
+
+    return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === SCHEDULER_START_ACCOUNT)
+  }, [walletInfo])
+
+  const schedulerPreconditionSummary = useCoinjoinPreconditionSummary(startJarUtxos || [])
   const isSchedulerPreconditionsFulfilled = useMemo(
     () => schedulerPreconditionSummary.isFulfilled,
     [schedulerPreconditionSummary]
@@ -328,9 +275,9 @@ export default function Jam() {
                 {schedulerPreconditionSummary.numberOfMissingUtxos > 0 ? (
                   <Trans i18nKey="scheduler.precondition.hint_missing_utxos">
                     To run the scheduler you need at least one UTXO with{' '}
-                    <strong>{{ minConfirmations: SCHEDULER_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
+                    <strong>{{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
                     confirmations. Fund your wallet and wait for{' '}
-                    <strong>{{ minConfirmations: SCHEDULER_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
+                    <strong>{{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
                     blocks.
                   </Trans>
                 ) : schedulerPreconditionSummary.amountOfMissingConfirmations > 0 ? (
@@ -340,7 +287,7 @@ export default function Jam() {
                       {{
                         /* this comment is a hack for "prettier" and prevents the removal of "{' '}" 
                            (which is essential for parameterized translations to work). */
-                        minConfirmations: SCHEDULER_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
+                        minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
                       }}
                     </strong>{' '}
                     or more confirmations. Wait for{' '}
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index 851cc4e..4c4fb46 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -8,11 +8,14 @@ import ToggleSwitch from './ToggleSwitch'
 import Sprite from './Sprite'
 import Balance from './Balance'
 import JarSelectorModal from './JarSelectorModal'
+
 import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useLoadConfigValue } from '../context/ServiceConfigContext'
 import { useSettings } from '../context/SettingsContext'
 import { useBalanceSummary } from '../hooks/BalanceSummary'
+import { COINJOIN_PRECONDITIONS, useCoinjoinPreconditionSummary } from '../hooks/CoinjoinPrecondition'
+
 import * as Api from '../libs/JmWalletApi'
 import { SATS, formatBtc, formatSats } from '../utils'
 import { routes } from '../constants/routes'
@@ -22,6 +25,20 @@ const IS_COINJOIN_DEFAULT_VAL = true
 // initial value for `minimum_makers` from the default joinmarket.cfg (last check on 2022-02-20 of v0.9.5)
 const MINIMUM_MAKERS_DEFAULT_VAL = 4
 
+const INITIAL_DESTINATION = null
+const INITIAL_ACCOUNT = 0
+const INITIAL_AMOUNT = null
+
+const initialNumCollaborators = (minValue) => {
+  const defaultNumber = pseudoRandomNumber(8, 10)
+
+  if (minValue > 8) {
+    return minValue + pseudoRandomNumber(0, 2)
+  }
+
+  return defaultNumber
+}
+
 // not cryptographically random. returned number is in range [min, max] (both inclusive).
 const pseudoRandomNumber = (min, max) => {
   return Math.round(Math.random() * (max - min)) + min
@@ -179,6 +196,54 @@ function SweepAccordionToggle({ eventKey }) {
   )
 }
 
+function CoinjoinPreconditionFailedAlert({ coinjoinPreconditionSummary }) {
+  return (
+    <rb.Alert variant="warning" className="mb-4">
+      <>
+        {coinjoinPreconditionSummary.numberOfMissingUtxos > 0 ? (
+          <Trans i18nKey="send.coinjoin_precondition.hint_missing_utxos">
+            To execute a collaborative transaction you need at least one UTXO with{' '}
+            <strong>{{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
+            confirmations in the source jar. Select another jar to send from or fund this jar and wait for{' '}
+            <strong>{{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong> blocks.
+          </Trans>
+        ) : coinjoinPreconditionSummary.amountOfMissingConfirmations > 0 ? (
+          <Trans i18nKey="send.coinjoin_precondition.hint_missing_confirmations">
+            A collaborative transaction requires one of your UTXOs to have{' '}
+            <strong>
+              {{
+                /* this comment is a hack for "prettier" and prevents the removal of "{' '}" 
+                 (which is essential for parameterized translations to work). */
+                minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
+              }}
+            </strong>{' '}
+            or more confirmations. Select another jar to send from or wait for{' '}
+            <strong>
+              {{ amountOfMissingConfirmations: coinjoinPreconditionSummary.amountOfMissingConfirmations }}
+            </strong>{' '}
+            more block(s).
+          </Trans>
+        ) : (
+          coinjoinPreconditionSummary.amountOfMissingOverallRetries > 0 && (
+            <Trans i18nKey="send.coinjoin_precondition.hint_missing_overall_retries">
+              You've tried executing a collaborative transaction from this jar unsuccessfully too many times in a row.
+              For security reasons, you need a fresh UTXO to try again. See{' '}
+              <a
+                href="https://github.com/JoinMarket-Org/joinmarket/wiki/Sourcing-commitments-for-joins#sourcing-external-commitments"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                the docs
+              </a>{' '}
+              for more information.
+            </Trans>
+          )
+        )}
+      </>
+    </rb.Alert>
+  )
+}
+
 export default function Send() {
   const { t } = useTranslation()
   const wallet = useCurrentWallet()
@@ -220,22 +285,9 @@ export default function Send() {
     setTakerStartedInfoAlert((current) => (isCoinjoinInProgress ? current : null))
   }, [isCoinjoinInProgress])
 
-  const initialDestination = null
-  const initialAccount = 0
-  const initialAmount = null
-  const initialNumCollaborators = (minValue) => {
-    const defaultNumber = pseudoRandomNumber(8, 10)
-
-    if (minValue > 8) {
-      return minValue + pseudoRandomNumber(0, 2)
-    }
-
-    return defaultNumber
-  }
-
-  const [destination, setDestination] = useState(initialDestination)
-  const [account, setAccount] = useState(parseInt(location.state?.account, 10) || initialAccount)
-  const [amount, setAmount] = useState(initialAmount)
+  const [destination, setDestination] = useState(INITIAL_DESTINATION)
+  const [account, setAccount] = useState(parseInt(location.state?.account, 10) || INITIAL_ACCOUNT)
+  const [amount, setAmount] = useState(INITIAL_AMOUNT)
   // see https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/USAGE.md#try-out-a-coinjoin-using-sendpaymentpy
   const [numCollaborators, setNumCollaborators] = useState(initialNumCollaborators(minNumCollaborators))
   const [formIsValid, setFormIsValid] = useState(false)
@@ -246,18 +298,36 @@ export default function Send() {
     [balanceSummary, account]
   )
 
+  const sourceJarUtxos = useMemo(() => {
+    if (!walletInfo) return null
+
+    return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === account)
+  }, [walletInfo, account])
+
+  const coinjoinPreconditionSummary = useCoinjoinPreconditionSummary(sourceJarUtxos || [])
+
   useEffect(() => {
     if (
       isValidAddress(destination) &&
       isValidAccount(account) &&
       isValidAmount(amount, isSweep) &&
-      (isCoinjoin ? isValidNumCollaborators(numCollaborators, minNumCollaborators) : true)
+      (isCoinjoin ? isValidNumCollaborators(numCollaborators, minNumCollaborators) : true) &&
+      (isCoinjoin ? coinjoinPreconditionSummary.isFulfilled : true)
     ) {
       setFormIsValid(true)
     } else {
       setFormIsValid(false)
     }
-  }, [destination, account, amount, numCollaborators, minNumCollaborators, isCoinjoin, isSweep])
+  }, [
+    destination,
+    account,
+    amount,
+    numCollaborators,
+    minNumCollaborators,
+    isCoinjoin,
+    isSweep,
+    coinjoinPreconditionSummary,
+  ])
 
   useEffect(() => {
     if (isSweep) {
@@ -338,6 +408,8 @@ export default function Send() {
       key: { section: 'POLICY', field: 'minimum_makers' },
     })
       .then((data) => {
+        if (abortCtrl.signal.aborted) return
+
         const minimumMakers = parseInt(data.value, 10)
         setMinNumCollaborators(minimumMakers)
         setNumCollaborators(initialNumCollaborators(minimumMakers))
@@ -458,8 +530,8 @@ export default function Send() {
         : await sendPayment(account, destination, amount)
 
       if (success) {
-        setDestination(initialDestination)
-        setAmount(initialAmount)
+        setDestination(INITIAL_DESTINATION)
+        setAmount(INITIAL_AMOUNT)
         setNumCollaborators(initialNumCollaborators(minNumCollaborators))
         setIsCoinjoin(IS_COINJOIN_DEFAULT_VAL)
         setIsSweep(false)
@@ -602,6 +674,10 @@ export default function Send() {
           <rb.Alert variant={takerStartedInfoAlert.variant}>{takerStartedInfoAlert.message}</rb.Alert>
         )}
 
+        {!isLoading && !isOperationDisabled && isCoinjoin && !coinjoinPreconditionSummary.isFulfilled && (
+          <CoinjoinPreconditionFailedAlert coinjoinPreconditionSummary={coinjoinPreconditionSummary} />
+        )}
+
         {!isLoading && balanceSummary && (
           <JarSelectorModal
             isShown={destinationJarPickerShown}
@@ -741,7 +817,7 @@ export default function Send() {
                     onClick={() => {
                       if (destinationJar !== null) {
                         setDestinationJar(null)
-                        setDestination(initialDestination)
+                        setDestination(INITIAL_DESTINATION)
                       } else {
                         setDestinationJarPickerShown(true)
                       }
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx
index 2d7e51f..80bbd77 100644
--- a/src/components/Settings.jsx
+++ b/src/components/Settings.jsx
@@ -124,14 +124,13 @@ export default function Settings({ stopWallet }) {
         const { name: walletName, token } = currentWallet
         const res = await Api.getWalletLock({ walletName, token })
 
+        setLockingWallet(false)
         if (res.ok || res.status === 401) {
           stopWallet()
+          navigate(destination)
         } else {
           await Api.Helper.throwError(res)
         }
-
-        setLockingWallet(false)
-        navigate(destination)
       } catch (e) {
         setLockingWallet(false)
         setAlert({ variant: 'danger', dismissible: false, message: e.message })
diff --git a/src/components/Wallet.jsx b/src/components/Wallet.jsx
index 33dfb00..45ede87 100644
--- a/src/components/Wallet.jsx
+++ b/src/components/Wallet.jsx
@@ -1,11 +1,9 @@
-import React, { useState, useCallback, useEffect } from 'react'
-import { Link, useNavigate } from 'react-router-dom'
+import React, { useCallback } from 'react'
+import { Link } from 'react-router-dom'
 import { Formik } from 'formik'
 import * as rb from 'react-bootstrap'
-import { ConfirmModal } from './Modal'
 import { useTranslation } from 'react-i18next'
 import { walletDisplayName } from '../utils'
-import * as Api from '../libs/JmWalletApi'
 import { TabActivityIndicator, JoiningIndicator } from './ActivityIndicators'
 import { routes } from '../constants/routes'
 import styles from './Wallet.module.css'
@@ -104,146 +102,17 @@ const WalletUnlockForm = ({ walletName, unlockWallet }) => {
 
 export default function Wallet({
   name,
-  noneActive,
+  lockWallet,
+  unlockWallet,
   isActive,
-  hasToken,
   makerRunning,
   coinjoinInProgress,
-  currentWallet,
-  startWallet,
-  stopWallet,
-  setAlert,
   ...props
 }) {
   const { t } = useTranslation()
-  const [showLockConfirmModal, setShowLockConfirmModal] = useState(false)
-
-  const navigate = useNavigate()
-
-  const unlockWallet = useCallback(
-    async (walletName, password) => {
-      if (currentWallet) {
-        setAlert({
-          variant: 'warning',
-          dismissible: false,
-          message:
-            currentWallet.name === name
-              ? // unlocking same wallet
-                t('wallets.wallet_preview.alert_wallet_already_unlocked', { walletName: walletDisplayName(name) })
-              : // unlocking another wallet while one is already unlocked
-                t('wallets.wallet_preview.alert_other_wallet_unlocked', { walletName: walletDisplayName(name) }),
-        })
-        return
-      }
-
-      setAlert(null)
-      try {
-        const res = await Api.postWalletUnlock({ walletName }, { password })
-        const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
-
-        const { walletname: unlockedWalletName, token } = body
-        startWallet(unlockedWalletName, token)
-        navigate(routes.wallet)
-      } catch (e) {
-        const message = e.message.replace('Wallet', walletName)
-        setAlert({ variant: 'danger', dismissible: false, message })
-      }
-    },
-    [currentWallet, name, setAlert, startWallet, t, navigate]
-  )
-
-  const lockWallet = useCallback(
-    async (lockableWalletName, { confirmed = false }) => {
-      if (!currentWallet || currentWallet.name !== lockableWalletName) {
-        setAlert({
-          variant: 'warning',
-          dismissible: false,
-          message: currentWallet
-            ? // locking another wallet while active one is still unlocked
-              t('wallets.wallet_preview.alert_other_wallet_unlocked', {
-                walletName: walletDisplayName(currentWallet.name),
-              })
-            : // locking without active wallet
-              t('wallets.wallet_preview.alert_wallet_already_locked', {
-                walletName: walletDisplayName(lockableWalletName),
-              }),
-        })
-        return
-      }
-
-      const needsLockConfirmation = !confirmed && (coinjoinInProgress || makerRunning)
-      if (needsLockConfirmation) {
-        setShowLockConfirmModal(true)
-        return
-      }
-
-      setAlert(null)
-
-      try {
-        const { name: walletName, token } = currentWallet
-
-        const res = await Api.getWalletLock({ walletName, token })
-
-        // On status OK or UNAUTHORIZED, stop the wallet and clear all local
-        // information. The token might have become invalid or another one might have been
-        // issued for the same wallet, etc.
-        // In any case, the user has no access to the wallet anymore.
-        if (res.ok || res.status === 401) {
-          stopWallet()
-        }
-
-        const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
-        const { walletname: lockedWalletName, already_locked } = body
-
-        setAlert({
-          variant: already_locked ? 'warning' : 'success',
-          dismissible: false,
-          message: already_locked
-            ? t('wallets.wallet_preview.alert_wallet_already_locked', {
-                walletName: walletDisplayName(lockedWalletName),
-              })
-            : t('wallets.wallet_preview.alert_wallet_locked_successfully', {
-                walletName: walletDisplayName(lockedWalletName),
-              }),
-        })
-      } catch (e) {
-        setAlert({ variant: 'danger', dismissible: false, message: e.message })
-      }
-    },
-    [currentWallet, coinjoinInProgress, makerRunning, setAlert, stopWallet, t]
-  )
-
-  useEffect(() => {
-    if (!currentWallet) {
-      setShowLockConfirmModal(false)
-    }
-  }, [currentWallet])
-
-  const onLockConfirmed = useCallback(async () => {
-    if (!currentWallet) return
-
-    setShowLockConfirmModal(false)
-    await lockWallet(currentWallet.name, { confirmed: true })
-  }, [currentWallet, lockWallet])
-
-  const showLockOptions = isActive && hasToken
-  const showUnlockOptions = noneActive || (isActive && !hasToken) || (!hasToken && !makerRunning && !coinjoinInProgress)
 
   return (
     <>
-      <ConfirmModal
-        isShown={showLockConfirmModal}
-        title={t('wallets.wallet_preview.modal_lock_wallet_title')}
-        body={
-          (makerRunning
-            ? t('wallets.wallet_preview.modal_lock_wallet_maker_running_text')
-            : t('wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text')) +
-          ' ' +
-          t('wallets.wallet_preview.modal_lock_wallet_alternative_action_text')
-        }
-        onCancel={() => setShowLockConfirmModal(false)}
-        onConfirm={onLockConfirmed}
-      />
       <rb.Card {...props}>
         <rb.Card.Body>
           <div className="w-100 d-flex justify-content-between align-items-center flex-wrap py-1">
@@ -269,11 +138,11 @@ export default function Wallet({
               )}
             </div>
 
-            {showLockOptions ? (
+            {lockWallet ? (
               <WalletLockForm walletName={name} lockWallet={lockWallet} />
             ) : (
               <div className={`w-100 mt-3 mt-md-0 ${styles['wallet-password-input']}`}>
-                {showUnlockOptions && <WalletUnlockForm walletName={name} unlockWallet={unlockWallet} />}
+                {unlockWallet && <WalletUnlockForm walletName={name} unlockWallet={unlockWallet} />}
               </div>
             )}
           </div>
diff --git a/src/components/Wallets.jsx b/src/components/Wallets.jsx
index e2af5b0..1881121 100644
--- a/src/components/Wallets.jsx
+++ b/src/components/Wallets.jsx
@@ -1,16 +1,16 @@
-import React, { useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
+import React, { useMemo, useEffect, useCallback, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
 import * as rb from 'react-bootstrap'
 import Sprite from './Sprite'
 import Alert from './Alert'
 import Wallet from './Wallet'
 import PageTitle from './PageTitle'
-import { useCurrentWallet } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useTranslation } from 'react-i18next'
 import { walletDisplayName } from '../utils'
 import * as Api from '../libs/JmWalletApi'
 import { routes } from '../constants/routes'
+import { ConfirmModal } from './Modal'
 
 function arrayEquals(a, b) {
   return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index])
@@ -24,14 +24,124 @@ function sortWallets(wallets, activeWalletName = null) {
   }
 }
 
-export default function Wallets({ startWallet, stopWallet }) {
+export default function Wallets({ currentWallet, startWallet, stopWallet }) {
   const { t } = useTranslation()
-  const currentWallet = useCurrentWallet()
+  const navigate = useNavigate()
   const serviceInfo = useServiceInfo()
   const reloadServiceInfo = useReloadServiceInfo()
   const [walletList, setWalletList] = useState(null)
   const [isLoading, setIsLoading] = useState(true)
   const [alert, setAlert] = useState(null)
+  const [showLockConfirmModal, setShowLockConfirmModal] = useState(false)
+
+  const coinjoinInProgress = useMemo(() => serviceInfo && serviceInfo.coinjoinInProgress, [serviceInfo])
+  const makerRunning = useMemo(() => serviceInfo && serviceInfo.makerRunning, [serviceInfo])
+
+  const unlockWallet = useCallback(
+    async (walletName, password) => {
+      if (currentWallet) {
+        setAlert({
+          variant: 'warning',
+          dismissible: false,
+          message:
+            currentWallet.name === walletName
+              ? // unlocking same wallet
+                t('wallets.wallet_preview.alert_wallet_already_unlocked', { walletName: walletDisplayName(walletName) })
+              : // unlocking another wallet while one is already unlocked
+                t('wallets.wallet_preview.alert_other_wallet_unlocked', { walletName: walletDisplayName(walletName) }),
+        })
+        return
+      }
+
+      setAlert(null)
+      try {
+        const res = await Api.postWalletUnlock({ walletName }, { password })
+        const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
+
+        const { walletname: unlockedWalletName, token } = body
+        startWallet(unlockedWalletName, token)
+        navigate(routes.wallet)
+      } catch (e) {
+        const message = e.message.replace('Wallet', walletName)
+        setAlert({ variant: 'danger', dismissible: false, message })
+      }
+    },
+    [currentWallet, setAlert, startWallet, t, navigate]
+  )
+
+  const lockWallet = useCallback(
+    async (lockableWalletName, { confirmed = false }) => {
+      if (!currentWallet || currentWallet.name !== lockableWalletName) {
+        setAlert({
+          variant: 'warning',
+          dismissible: false,
+          message: currentWallet
+            ? // locking another wallet while active one is still unlocked
+              t('wallets.wallet_preview.alert_other_wallet_unlocked', {
+                walletName: walletDisplayName(currentWallet.name),
+              })
+            : // locking without active wallet
+              t('wallets.wallet_preview.alert_wallet_already_locked', {
+                walletName: walletDisplayName(lockableWalletName),
+              }),
+        })
+        return
+      }
+
+      const needsLockConfirmation = !confirmed && (coinjoinInProgress || makerRunning)
+      if (needsLockConfirmation) {
+        setShowLockConfirmModal(true)
+        return
+      }
+
+      setAlert(null)
+
+      try {
+        const { name: walletName, token } = currentWallet
+
+        const res = await Api.getWalletLock({ walletName, token })
+
+        // On status OK or UNAUTHORIZED, stop the wallet and clear all local
+        // information. The token might have become invalid or another one might have been
+        // issued for the same wallet, etc.
+        // In any case, the user has no access to the wallet anymore.
+        if (res.ok || res.status === 401) {
+          stopWallet()
+        }
+
+        const body = await (res.ok ? res.json() : Api.Helper.throwError(res))
+        const { walletname: lockedWalletName, already_locked } = body
+
+        setAlert({
+          variant: already_locked ? 'warning' : 'success',
+          dismissible: false,
+          message: already_locked
+            ? t('wallets.wallet_preview.alert_wallet_already_locked', {
+                walletName: walletDisplayName(lockedWalletName),
+              })
+            : t('wallets.wallet_preview.alert_wallet_locked_successfully', {
+                walletName: walletDisplayName(lockedWalletName),
+              }),
+        })
+      } catch (e) {
+        setAlert({ variant: 'danger', dismissible: false, message: e.message })
+      }
+    },
+    [currentWallet, coinjoinInProgress, makerRunning, setAlert, stopWallet, t]
+  )
+
+  useEffect(() => {
+    if (!currentWallet) {
+      setShowLockConfirmModal(false)
+    }
+  }, [currentWallet])
+
+  const onLockConfirmed = useCallback(async () => {
+    if (!currentWallet) return
+
+    setShowLockConfirmModal(false)
+    await lockWallet(currentWallet.name, { confirmed: true })
+  }, [currentWallet, lockWallet])
 
   useEffect(() => {
     if (walletList && serviceInfo) {
@@ -76,50 +186,71 @@ export default function Wallets({ startWallet, stopWallet }) {
   }, [currentWallet, reloadServiceInfo, t])
 
   return (
-    <div className="wallets">
-      <PageTitle
-        title={t('wallets.title')}
-        subtitle={walletList?.length === 0 ? t('wallets.subtitle_no_wallets') : null}
-        center={true}
-      />
-      {alert && <Alert {...alert} />}
-      {isLoading ? (
-        <div className="d-flex justify-content-center align-items-center">
-          <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
-          <span>{t('wallets.text_loading')}</span>
-        </div>
-      ) : (
-        walletList?.map((wallet, index) => (
-          <Wallet
-            key={wallet}
-            name={wallet}
-            noneActive={!serviceInfo?.walletName}
-            isActive={serviceInfo?.walletName === wallet}
-            hasToken={currentWallet && currentWallet.token && currentWallet.name === serviceInfo?.walletName}
-            makerRunning={serviceInfo?.makerRunning}
-            coinjoinInProgress={serviceInfo?.coinjoinInProgress}
-            currentWallet={currentWallet}
-            startWallet={startWallet}
-            stopWallet={stopWallet}
-            setAlert={setAlert}
-            className={`bg-transparent rounded-0 border-start-0 border-end-0 ${
-              index === 0 ? 'border-top-1' : 'border-top-0'
-            }`}
-          />
-        ))
-      )}
-      <div className="d-flex justify-content-center">
-        <Link
-          to={routes.createWallet}
-          className={`btn mt-4 ${walletList?.length === 0 ? 'btn-lg btn-dark' : 'btn-outline-dark'}`}
-          data-testid="new-wallet-btn"
-        >
+    <>
+      <div className="wallets">
+        <PageTitle
+          title={t('wallets.title')}
+          subtitle={walletList?.length === 0 ? t('wallets.subtitle_no_wallets') : null}
+          center={true}
+        />
+        {alert && <Alert {...alert} />}
+        {isLoading ? (
           <div className="d-flex justify-content-center align-items-center">
-            <Sprite symbol="plus" width="24" height="24" className="me-2" />
-            <span>{t('wallets.button_new_wallet')}</span>
+            <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
+            <span>{t('wallets.text_loading')}</span>
           </div>
-        </Link>
+        ) : (
+          walletList?.map((wallet, index) => {
+            const noneActive = !serviceInfo?.walletName
+            const isActive = serviceInfo?.walletName === wallet
+            const hasToken = currentWallet && currentWallet.token && currentWallet.name === serviceInfo?.walletName
+
+            const showLockOptions = isActive && hasToken
+            const showUnlockOptions =
+              noneActive || (isActive && !hasToken) || (!hasToken && !makerRunning && !coinjoinInProgress)
+            return (
+              <Wallet
+                key={wallet}
+                name={wallet}
+                lockWallet={showLockOptions ? lockWallet : undefined}
+                unlockWallet={showUnlockOptions ? unlockWallet : undefined}
+                isActive={isActive}
+                makerRunning={makerRunning}
+                coinjoinInProgress={coinjoinInProgress}
+                className={`bg-transparent rounded-0 border-start-0 border-end-0 ${
+                  index === 0 ? 'border-top-1' : 'border-top-0'
+                }`}
+              />
+            )
+          })
+        )}
+        <div className="d-flex justify-content-center">
+          <Link
+            to={routes.createWallet}
+            className={`btn mt-4 ${walletList?.length === 0 ? 'btn-lg btn-dark' : 'btn-outline-dark'}`}
+            data-testid="new-wallet-btn"
+          >
+            <div className="d-flex justify-content-center align-items-center">
+              <Sprite symbol="plus" width="24" height="24" className="me-2" />
+              <span>{t('wallets.button_new_wallet')}</span>
+            </div>
+          </Link>
+        </div>
       </div>
-    </div>
+
+      <ConfirmModal
+        isShown={showLockConfirmModal}
+        title={t('wallets.wallet_preview.modal_lock_wallet_title')}
+        body={
+          (makerRunning
+            ? t('wallets.wallet_preview.modal_lock_wallet_maker_running_text')
+            : t('wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text')) +
+          ' ' +
+          t('wallets.wallet_preview.modal_lock_wallet_alternative_action_text')
+        }
+        onCancel={() => setShowLockConfirmModal(false)}
+        onConfirm={onLockConfirmed}
+      />
+    </>
   )
 }
diff --git a/src/constants/features.ts b/src/constants/features.ts
index 3931da2..d765f62 100644
--- a/src/constants/features.ts
+++ b/src/constants/features.ts
@@ -1,13 +1,11 @@
 interface Features {
   skipWalletBackupConfirmation: boolean
-  fidelityBonds: boolean
 }
 
 const devMode = process.env.NODE_ENV === 'development'
 
 const features: Features = {
   skipWalletBackupConfirmation: devMode,
-  fidelityBonds: devMode,
 }
 
 type Feature = keyof Features
diff --git a/src/hooks/CoinjoinPrecondition.ts b/src/hooks/CoinjoinPrecondition.ts
new file mode 100644
index 0000000..a931aeb
--- /dev/null
+++ b/src/hooks/CoinjoinPrecondition.ts
@@ -0,0 +1,50 @@
+import { useMemo } from 'react'
+import { isLocked } from '../hooks/BalanceSummary'
+import { Utxos } from '../context/WalletContext'
+
+export const COINJOIN_PRECONDITIONS = {
+  MIN_NUMBER_OF_UTXOS: 1, // min amount of utxos available
+  // https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.6/docs/SOURCING-COMMITMENTS.md#wait-for-at-least-5-confirmations
+  MIN_CONFIRMATIONS_OF_SINGLE_UTXO: 5, // at least one utxo needs X confirmations
+  MIN_OVERALL_REMAINING_RETRIES: 1, // amount of overall retries available
+}
+
+export interface CoinjoinPreconditionSummary {
+  isFulfilled: boolean
+  numberOfMissingUtxos: number
+  amountOfMissingConfirmations: number
+  amountOfMissingOverallRetries: number
+}
+
+export const useCoinjoinPreconditionSummary = (utxos: Utxos): CoinjoinPreconditionSummary => {
+  const eligibleUtxos = useMemo(() => {
+    return utxos.filter((it) => !it.frozen).filter((it) => !isLocked(it))
+  }, [utxos])
+
+  return useMemo(() => {
+    const numberOfMissingUtxos = Math.max(0, COINJOIN_PRECONDITIONS.MIN_NUMBER_OF_UTXOS - eligibleUtxos.length)
+
+    const overallRetriesRemaining = eligibleUtxos.reduce((acc, utxo) => acc + utxo.tries_remaining, 0)
+    const amountOfMissingOverallRetries = Math.max(
+      0,
+      COINJOIN_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES - overallRetriesRemaining
+    )
+
+    const maxConfirmations =
+      eligibleUtxos.length === 0 ? 0 : eligibleUtxos.reduce((acc, utxo) => Math.max(acc, utxo.confirmations), 0)
+    const amountOfMissingConfirmations = Math.max(
+      0,
+      COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO - maxConfirmations
+    )
+
+    const isFulfilled =
+      numberOfMissingUtxos === 0 && amountOfMissingOverallRetries === 0 && amountOfMissingConfirmations === 0
+
+    return {
+      isFulfilled,
+      numberOfMissingUtxos,
+      amountOfMissingConfirmations,
+      amountOfMissingOverallRetries,
+    }
+  }, [eligibleUtxos])
+}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 706c169..b81f74c 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -204,7 +204,12 @@
     "sweep_amount_breakdown_total_balance": "Total balance",
     "sweep_amount_breakdown_frozen_balance": "Frozen or locked balance",
     "sweep_amount_breakdown_estimated_amount": "Estimated amount to be sent",
-    "sweep_amount_breakdown_explanation": "A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that have been <1>frozen</1> or <3>time-locked</3>. Onchain transaction fees and market maker fees will be deducted from the amount so as to leave zero change. The exact transaction amount can only be calculated by JoinMarket at the point when the transaction is made. Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the <5>JoinMarket documentation</5> for more details."
+    "sweep_amount_breakdown_explanation": "A sweep transaction will consume all UTXOs of a mixdepth leaving no coins behind except those that have been <1>frozen</1> or <3>time-locked</3>. Onchain transaction fees and market maker fees will be deducted from the amount so as to leave zero change. The exact transaction amount can only be calculated by JoinMarket at the point when the transaction is made. Therefore the estimated amount shown might deviate from the actually sent amount. Refer to the <5>JoinMarket documentation</5> for more details.",
+    "coinjoin_precondition": {
+      "hint_missing_utxos": "To execute a collaborative transaction you need at least one UTXO with <2>{{ minConfirmations }}</2> confirmations in the source jar. Select another jar to send from or fund this jar and wait for <6>{{ minConfirmations }}</6> blocks.",
+      "hint_missing_confirmations": "A collaborative transaction requires one of your UTXOs to have <2>{{ minConfirmations }}</2> or more confirmations. Select another jar to send from or wait for <6>{{ amountOfMissingConfirmations }}</6> more block(s).",
+      "hint_missing_overall_retries": "You've tried executing a collaborative transaction from this jar unsuccessfully too many times in a row. For security reasons, you need a fresh UTXO to try again. See <2>the docs</2> for more information."
+    }
   },
   "receive": {
     "title": "Receive",


Test patch:
diff --git a/src/components/Wallet.test.jsx b/src/components/Wallet.test.jsx
index d84001e..edd2fba 100644
--- a/src/components/Wallet.test.jsx
+++ b/src/components/Wallet.test.jsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import { render, screen, waitFor, waitForElementToBeRemoved } from '../testUtils'
+import { render, screen, waitFor } from '../testUtils'
 import { act } from 'react-dom/test-utils'
 import user from '@testing-library/user-event'
 import * as apiMock from '../libs/JmWalletApi'
@@ -10,45 +10,31 @@ import Wallet from './Wallet'
 jest.mock('../libs/JmWalletApi', () => ({
   ...jest.requireActual('../libs/JmWalletApi'),
   getSession: jest.fn(),
-  postWalletUnlock: jest.fn(),
-  getWalletLock: jest.fn(),
 }))
 
-const mockedNavigate = jest.fn()
-jest.mock('react-router-dom', () => {
-  return {
-    ...jest.requireActual('react-router-dom'),
-    useNavigate: () => mockedNavigate,
-  }
-})
-
 describe('<Wallet />', () => {
   const dummyWalletName = 'dummy.jmdat'
-  const dummyToken = 'dummyToken'
+  const dummyPassword = 'correct horse battery staple'
 
-  const mockStartWallet = jest.fn()
-  const mockStopWallet = jest.fn()
-  const mockSetAlert = jest.fn()
+  const mockUnlockWallet = jest.fn()
+  const mockLockWallet = jest.fn()
 
   const setup = ({
     name,
+    lockWallet = undefined,
+    unlockWallet = undefined,
     isActive = false,
-    hasToken = false,
     makerRunning = false,
     coinjoinInProgress = false,
-    currentWallet = null,
   }) => {
     render(
       <Wallet
         name={name}
+        lockWallet={lockWallet}
+        unlockWallet={unlockWallet}
         isActive={isActive}
-        hasToken={hasToken}
-        makerRunning={makerRunning}
         coinjoinInProgress={coinjoinInProgress}
-        currentWallet={currentWallet}
-        startWallet={mockStartWallet}
-        stopWallet={mockStopWallet}
-        setAlert={mockSetAlert}
+        makerRunning={makerRunning}
       />
     )
   }
@@ -63,61 +49,23 @@ describe('<Wallet />', () => {
 
     expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
     expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
-    expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
+    expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).not.toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_unlock')).not.toBeInTheDocument()
     expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
     expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
   })
 
   it('should unlock inactive wallet successfully', async () => {
-    apiMock.postWalletUnlock.mockResolvedValueOnce({
-      ok: true,
-      json: () => Promise.resolve({ walletname: dummyWalletName, token: dummyToken }),
-    })
-
-    await act(async () => setup({ name: dummyWalletName }))
-
-    expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
-    expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
-
-    act(() => {
-      user.type(
-        screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'),
-        'correct horse battery staple'
-      )
-    })
-
-    await act(async () => {
-      const unlockWalletButton = screen.getByText('wallets.wallet_preview.button_unlock')
-      user.click(unlockWalletButton)
-
-      await waitFor(() => screen.findByText(/wallets.wallet_preview.button_unlocking/))
-      await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
-    })
-
-    expect(mockStartWallet).toHaveBeenCalledWith(dummyWalletName, dummyToken)
-    expect(mockedNavigate).toHaveBeenCalledWith('/wallet')
-  })
-
-  it('should add alert if unlocking of inactive wallet fails', async () => {
-    const apiErrorMessage = 'ANY_ERROR_MESSAGE with template <Wallet>'
-    apiMock.postWalletUnlock.mockResolvedValueOnce({
-      ok: false,
-      json: () => Promise.resolve({ message: apiErrorMessage }),
-    })
-
-    act(() => setup({ name: dummyWalletName }))
+    await act(async () => setup({ name: dummyWalletName, unlockWallet: mockUnlockWallet }))
 
     expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
     expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
     expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
 
     act(() => {
-      user.type(
-        screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'),
-        'correct horse battery staple'
-      )
+      user.paste(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'), dummyPassword)
     })
 
     await act(async () => {
@@ -128,111 +76,49 @@ describe('<Wallet />', () => {
       await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
     })
 
-    expect(mockStartWallet).not.toHaveBeenCalled()
-    expect(mockedNavigate).not.toHaveBeenCalled()
-    expect(mockSetAlert).toHaveBeenCalledWith({
-      variant: 'danger',
-      dismissible: false,
-      message: apiErrorMessage.replace('Wallet', dummyWalletName),
-    })
+    expect(mockUnlockWallet).toHaveBeenCalledWith(dummyWalletName, dummyPassword)
   })
 
-  it('should render active wallet without errors', () => {
-    act(() => setup({ name: dummyWalletName, isActive: true, hasToken: true }))
-
-    expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.button_open')).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
-    expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).not.toBeInTheDocument()
-    expect(screen.queryByText('wallets.wallet_preview.button_unlock')).not.toBeInTheDocument()
-  })
-
-  it('should lock active wallet successfully', async () => {
-    apiMock.getWalletLock.mockResolvedValueOnce({
-      ok: true,
-      json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
-    })
-
+  it('should provide ability to unlock active wallet (i.e. when auth token is missing)', () => {
     act(() =>
       setup({
         name: dummyWalletName,
         isActive: true,
-        hasToken: true,
-        currentWallet: { name: dummyWalletName, token: dummyToken },
+        unlockWallet: mockUnlockWallet,
       })
     )
 
+    expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
     expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
-
-    await act(async () => {
-      const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
-      user.click(lockWalletButton)
-
-      await waitFor(() => screen.findByText(/wallet_preview.button_locking/))
-      await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
-    })
-
-    expect(mockStopWallet).toHaveBeenCalled()
-    expect(mockSetAlert).toHaveBeenCalledWith({
-      variant: 'success',
-      dismissible: false,
-      message: `wallets.wallet_preview.alert_wallet_locked_successfully`,
-    })
+    expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
   })
 
-  it('should add alert if locking active wallet fails', async () => {
-    const apiErrorMessage = 'ANY_ERROR_MESSAGE'
-    apiMock.getWalletLock.mockResolvedValueOnce({
-      ok: false,
-      status: 500, // any other error status than 401
-      json: () => Promise.resolve({ message: apiErrorMessage }),
-    })
-
+  it('should render active wallet without errors', () => {
     act(() =>
       setup({
         name: dummyWalletName,
         isActive: true,
-        hasToken: true,
-        currentWallet: { name: dummyWalletName, token: dummyToken },
+        lockWallet: mockLockWallet,
       })
     )
 
+    expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
     expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
+    expect(screen.getByText('wallets.wallet_preview.button_open')).toBeInTheDocument()
     expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
-
-    await act(async () => {
-      const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
-      user.click(lockWalletButton)
-
-      await waitFor(() => screen.findByText(/wallets.wallet_preview.button_locking/))
-      await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
-    })
-
-    // on errors other than 401, stopWallet should not have been called
-    expect(mockStopWallet).not.toHaveBeenCalled()
-    expect(mockSetAlert).toHaveBeenCalledWith({
-      variant: 'danger',
-      dismissible: false,
-      message: apiErrorMessage,
-    })
+    expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).not.toBeInTheDocument()
+    expect(screen.queryByText('wallets.wallet_preview.button_unlock')).not.toBeInTheDocument()
   })
 
-  it('should add alert but clear wallet if locking active wallet fails with UNAUTHORIZED', async () => {
-    const apiErrorMessage = 'Invalid credentials.'
-    apiMock.getWalletLock.mockResolvedValueOnce({
-      ok: false,
-      status: 401,
-      json: () => Promise.resolve({ message: apiErrorMessage }),
-    })
-
+  it('should lock active wallet successfully', async () => {
     act(() =>
       setup({
         name: dummyWalletName,
         isActive: true,
-        hasToken: true,
-        currentWallet: { name: dummyWalletName, token: dummyToken },
+        lockWallet: mockLockWallet,
       })
     )
 
@@ -243,28 +129,11 @@ describe('<Wallet />', () => {
       const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
       user.click(lockWalletButton)
 
-      await waitFor(() => screen.findByText(/wallets.wallet_preview.button_locking/))
+      await waitFor(() => screen.findByText(/wallet_preview.button_locking/))
       await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
     })
 
-    // on 401 errors, stopWallet *should* have been called
-    expect(mockStopWallet).toHaveBeenCalled()
-    expect(mockSetAlert).toHaveBeenCalledWith({
-      variant: 'danger',
-      dismissible: false,
-      message: apiErrorMessage,
-    })
-  })
-
-  it('should provide ability to unlock active wallet when token is missing', () => {
-    act(() => setup({ name: dummyWalletName, isActive: true, hasToken: false }))
-
-    expect(screen.getByText(walletDisplayName(dummyWalletName))).toBeInTheDocument()
-    expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
-    expect(screen.queryByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
-    expect(screen.queryByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
-    expect(screen.queryByText('wallets.wallet_preview.button_open')).not.toBeInTheDocument()
-    expect(screen.queryByText('wallets.wallet_preview.button_lock')).not.toBeInTheDocument()
+    expect(mockLockWallet).toHaveBeenCalledWith(dummyWalletName, { confirmed: false })
   })
 
   it('should display earn indicator when maker is running', () => {
@@ -280,71 +149,4 @@ describe('<Wallet />', () => {
     expect(document.querySelector('.joining-indicator')).toBeInTheDocument()
     expect(document.querySelector('.earn-indicator')).toBeNull()
   })
-
-  it.each`
-    makerRunning | coinjoinInProgress | expectedModalBody
-    ${true}      | ${false}           | ${'wallets.wallet_preview.modal_lock_wallet_maker_running_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
-    ${false}     | ${true}            | ${'wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
-  `(
-    'should confirm locking wallet if maker ($makerRunning) or taker ($coinjoinInProgress) is running',
-    async ({ makerRunning, coinjoinInProgress, expectedModalBody }) => {
-      apiMock.getWalletLock.mockResolvedValueOnce({
-        ok: true,
-        json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
-      })
-
-      act(() =>
-        setup({
-          name: dummyWalletName,
-          isActive: true,
-          hasToken: true,
-          makerRunning,
-          coinjoinInProgress,
-          currentWallet: { name: dummyWalletName, token: dummyToken },
-        })
-      )
-
-      // modal is initially not shown
-      expect(screen.queryByText('wallets.wallet_preview.modal_lock_wallet_title')).not.toBeInTheDocument()
-      expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
-
-      await act(async () => {
-        // click on the "lock" button
-        const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
-        user.click(lockWalletButton)
-      })
-
-      // modal appeared
-      expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
-      expect(screen.getByText(expectedModalBody)).toBeInTheDocument()
-      expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument()
-      expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
-
-      act(() => {
-        // click on the modal's "cancel" button
-        const lockWalletButton = screen.getByText('modal.confirm_button_reject')
-        user.click(lockWalletButton)
-      })
-      await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
-
-      expect(mockStopWallet).not.toHaveBeenCalled()
-
-      await act(async () => {
-        const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
-        user.click(lockWalletButton)
-      })
-
-      // modal appeared
-      expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
-
-      act(() => {
-        // click on the modal's "confirm" button
-        const lockWalletButton = screen.getByText('modal.confirm_button_accept')
-        user.click(lockWalletButton)
-      })
-      await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
-
-      expect(mockStopWallet).toHaveBeenCalled()
-    }
-  )
 })
diff --git a/src/components/Wallets.test.jsx b/src/components/Wallets.test.jsx
index 3648f98..7df4817 100644
--- a/src/components/Wallets.test.jsx
+++ b/src/components/Wallets.test.jsx
@@ -1,6 +1,7 @@
 import React from 'react'
-import { render, screen, waitForElementToBeRemoved } from '../testUtils'
+import { render, screen, waitFor, waitForElementToBeRemoved } from '../testUtils'
 import { act } from 'react-dom/test-utils'
+import user from '@testing-library/user-event'
 
 import * as apiMock from '../libs/JmWalletApi'
 
@@ -10,11 +11,24 @@ jest.mock('../libs/JmWalletApi', () => ({
   ...jest.requireActual('../libs/JmWalletApi'),
   getSession: jest.fn(),
   getWalletAll: jest.fn(),
+  postWalletUnlock: jest.fn(),
+  getWalletLock: jest.fn(),
 }))
 
+const mockedNavigate = jest.fn()
+jest.mock('react-router-dom', () => {
+  return {
+    ...jest.requireActual('react-router-dom'),
+    useNavigate: () => mockedNavigate,
+  }
+})
+
 describe('<Wallets />', () => {
-  const setup = () => {
-    render(<Wallets />)
+  const mockStartWallet = jest.fn()
+  const mockStopWallet = jest.fn()
+
+  const setup = ({ currentWallet = null }) => {
+    render(<Wallets currentWallet={currentWallet} startWallet={mockStartWallet} stopWallet={mockStopWallet} />)
   }
 
   beforeEach(() => {
@@ -27,7 +41,7 @@ describe('<Wallets />', () => {
     apiMock.getSession.mockResolvedValueOnce(neverResolvingPromise)
     apiMock.getWalletAll.mockResolvedValueOnce(neverResolvingPromise)
 
-    act(setup)
+    act(() => setup({}))
 
     expect(screen.getByText('wallets.title')).toBeInTheDocument()
     expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
@@ -42,7 +56,7 @@ describe('<Wallets />', () => {
       ok: false,
     })
 
-    act(setup)
+    act(() => setup({}))
 
     expect(screen.getByText('wallets.title')).toBeInTheDocument()
     expect(screen.getByText('wallets.button_new_wallet')).toBeInTheDocument()
@@ -69,7 +83,7 @@ describe('<Wallets />', () => {
       json: () => Promise.resolve({ wallets: [] }),
     })
 
-    act(setup)
+    act(() => setup({}))
 
     expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
 
@@ -101,7 +115,7 @@ describe('<Wallets />', () => {
       json: () => Promise.resolve({ wallets: ['wallet0.jmdat', 'wallet1.jmdat'] }),
     })
 
-    act(setup)
+    act(() => setup({}))
 
     expect(screen.getByText('wallets.text_loading')).toBeInTheDocument()
     await waitForElementToBeRemoved(screen.getByText('wallets.text_loading'))
@@ -115,4 +129,269 @@ describe('<Wallets />', () => {
     expect(callToActionButton.classList.contains('btn')).toBe(true)
     expect(callToActionButton.classList.contains('btn-lg')).toBe(false)
   })
+
+  describe('<Wallets /> lock/unlock flow', () => {
+    const dummyWalletName = 'dummy.jmdat'
+    const dummyToken = 'dummyToken'
+    const dummyPassword = 'correct horse battery staple'
+
+    it('should unlock inactive wallet successfully', async () => {
+      apiMock.getSession.mockResolvedValueOnce({
+        ok: true,
+        json: () =>
+          Promise.resolve({
+            session: false,
+            maker_running: false,
+            coinjoin_in_process: false,
+            wallet_name: 'None',
+          }),
+      })
+      apiMock.getWalletAll.mockResolvedValueOnce({
+        ok: true,
+        json: () => Promise.resolve({ wallets: [dummyWalletName] }),
+      })
+      apiMock.postWalletUnlock.mockResolvedValueOnce({
+        ok: true,
+        json: () => Promise.resolve({ walletname: dummyWalletName, token: dummyToken }),
+      })
+
+      await act(async () => setup({}))
+
+      expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
+      expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
+      expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
+
+      act(() => {
+        user.paste(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'), dummyPassword)
+      })
+
+      await act(async () => {
+        const unlockWalletButton = screen.getByText('wallets.wallet_preview.button_unlock')
+        user.click(unlockWalletButton)
+
+        await waitFor(() => screen.findByText(/wallets.wallet_preview.button_unlocking/))
+        await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
+      })
+
+      expect(mockStartWallet).toHaveBeenCalledWith(dummyWalletName, dummyToken)
+      expect(mockedNavigate).toHaveBeenCalledWith('/wallet')
+    })
+
+    it('should add alert if unlocking of inactive wallet fails', async () => {
+      const apiErrorMessage = 'ANY_ERROR_MESSAGE with template <Wallet>'
+
+      apiMock.getSession.mockResolvedValueOnce({
+        ok: true,
+        json: () =>
+          Promise.resolve({
+            session: false,
+            maker_running: false,
+            coinjoin_in_process: false,
+            wallet_name: 'None',
+          }),
+      })
+      apiMock.getWalletAll.mockResolvedValueOnce({
+        ok: true,
+        json: () => Promise.resolve({ wallets: [dummyWalletName] }),
+      })
+      apiMock.postWalletUnlock.mockResolvedValueOnce({
+        ok: false,
+        json: () => Promise.resolve({ message: apiErrorMessage }),
+      })
+
+      await act(async () => setup({}))
+
+      expect(screen.getByText('wallets.wallet_preview.wallet_inactive')).toBeInTheDocument()
+      expect(screen.getByText('wallets.wallet_preview.button_unlock')).toBeInTheDocument()
+      expect(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password')).toBeInTheDocument()
+
+      act(() => {
+        user.paste(screen.getByPlaceholderText('wallets.wallet_preview.placeholder_password'), dummyPassword)
+      })
+
+      await act(async () => {
+        const unlockWalletButton = screen.getByText('wallets.wallet_preview.button_unlock')
+        user.click(unlockWalletButton)
+
+        await waitFor(() => screen.findByText(/wallets.wallet_preview.button_unlocking/))
+        await waitFor(() => screen.findByText('wallets.wallet_preview.button_unlock'))
+      })
+
+      expect(mockStartWallet).not.toHaveBeenCalled()
+      expect(mockedNavigate).not.toHaveBeenCalled()
+
+      expect(screen.getByText(apiErrorMessage.replace('Wallet', dummyWalletName))).toBeInTheDocument()
+    })
+
+    it('should lock active wallet successfully', async () => {
+      apiMock.getSession.mockResolvedValueOnce({
+        ok: true,
+        json: () =>
+          Promise.resolve({
+            session: true,
+            maker_running: false,
+            coinjoin_in_process: false,
+            wallet_name: dummyWalletName,
+          }),
+      })
+      apiMock.getWalletAll.mockResolvedValueOnce({
+        ok: true,
+        json: () => Promise.resolve({ wallets: [dummyWalletName] }),
+      })
+      apiMock.getWalletLock.mockResolvedValueOnce({
+        ok: true,
+        json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
+      })
+
+      await act(async () =>
+        setup({
+          currentWallet: {
+            name: dummyWalletName,
+            token: dummyToken,
+          },
+        })
+      )
+
+      expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
+      expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
+
+      await act(async () => {
+        const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
+        user.click(lockWalletButton)
+
+        await waitFor(() => screen.findByText(/wallet_preview.button_locking/))
+        await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
+      })
+
+      expect(mockStopWallet).toHaveBeenCalled()
+      expect(screen.getByText('wallets.wallet_preview.alert_wallet_locked_successfully')).toBeInTheDocument()
+    })
+
+    it('should add alert but clear wallet if locking active wallet fails with UNAUTHORIZED', async () => {
+      const apiErrorMessage = 'Invalid credentials.'
+
+      apiMock.getSession.mockResolvedValueOnce({
+        ok: true,
+        json: () =>
+          Promise.resolve({
+            session: true,
+            maker_running: false,
+            coinjoin_in_process: false,
+            wallet_name: dummyWalletName,
+          }),
+      })
+      apiMock.getWalletAll.mockResolvedValueOnce({
+        ok: true,
+        json: () => Promise.resolve({ wallets: [dummyWalletName] }),
+      })
+      apiMock.getWalletLock.mockResolvedValueOnce({
+        ok: false,
+        status: 401,
+        json: () => Promise.resolve({ message: apiErrorMessage }),
+      })
+
+      await act(async () =>
+        setup({
+          currentWallet: {
+            name: dummyWalletName,
+            token: dummyToken,
+          },
+        })
+      )
+
+      expect(screen.getByText('wallets.wallet_preview.wallet_active')).toBeInTheDocument()
+      expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
+
+      await act(async () => {
+        const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
+        user.click(lockWalletButton)
+
+        await waitFor(() => screen.findByText(/wallets.wallet_preview.button_locking/))
+        await waitFor(() => screen.findByText('wallets.wallet_preview.button_lock'))
+      })
+
+      // on 401 errors, stopWallet *should* have been called
+      expect(mockStopWallet).toHaveBeenCalled()
+      expect(screen.getByText(apiErrorMessage)).toBeInTheDocument()
+    })
+
+    it.each`
+      makerRunning | coinjoinInProgress | expectedModalBody
+      ${true}      | ${false}           | ${'wallets.wallet_preview.modal_lock_wallet_maker_running_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
+      ${false}     | ${true}            | ${'wallets.wallet_preview.modal_lock_wallet_coinjoin_in_progress_text wallets.wallet_preview.modal_lock_wallet_alternative_action_text'}
+    `(
+      'should confirm locking wallet if maker ($makerRunning) or taker ($coinjoinInProgress) is running',
+      async ({ makerRunning, coinjoinInProgress, expectedModalBody }) => {
+        apiMock.getSession.mockResolvedValueOnce({
+          ok: true,
+          json: () =>
+            Promise.resolve({
+              session: true,
+              maker_running: makerRunning,
+              coinjoin_in_process: coinjoinInProgress,
+              wallet_name: dummyWalletName,
+            }),
+        })
+        apiMock.getWalletAll.mockResolvedValueOnce({
+          ok: true,
+          json: () => Promise.resolve({ wallets: [dummyWalletName] }),
+        })
+        apiMock.getWalletLock.mockResolvedValueOnce({
+          ok: true,
+          json: () => Promise.resolve({ walletname: dummyWalletName, already_locked: false }),
+        })
+
+        await act(async () =>
+          setup({
+            currentWallet: {
+              name: dummyWalletName,
+              token: dummyToken,
+            },
+          })
+        )
+
+        // modal is initially not shown
+        expect(screen.queryByText('wallets.wallet_preview.modal_lock_wallet_title')).not.toBeInTheDocument()
+        expect(screen.getByText('wallets.wallet_preview.button_lock')).toBeInTheDocument()
+
+        await act(async () => {
+          // click on the "lock" button
+          const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
+          user.click(lockWalletButton)
+        })
+
+        // modal appeared
+        expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
+        expect(screen.getByText(expectedModalBody)).toBeInTheDocument()
+        expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument()
+        expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument()
+
+        act(() => {
+          // click on the modal's "cancel" button
+          const lockWalletButton = screen.getByText('modal.confirm_button_reject')
+          user.click(lockWalletButton)
+        })
+        await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
+
+        expect(mockStopWallet).not.toHaveBeenCalled()
+
+        await act(async () => {
+          const lockWalletButton = screen.getByText('wallets.wallet_preview.button_lock')
+          user.click(lockWalletButton)
+        })
+
+        // modal appeared
+        expect(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title')).toBeInTheDocument()
+
+        act(() => {
+          // click on the modal's "confirm" button
+          const lockWalletButton = screen.getByText('modal.confirm_button_accept')
+          user.click(lockWalletButton)
+        })
+        await waitForElementToBeRemoved(screen.getByText('wallets.wallet_preview.modal_lock_wallet_title'))
+
+        expect(mockStopWallet).toHaveBeenCalled()
+      }
+    )
+  })
 })
diff --git a/src/hooks/CoinjoinPrecondition.test.tsx b/src/hooks/CoinjoinPrecondition.test.tsx
new file mode 100644
index 0000000..9b59218
--- /dev/null
+++ b/src/hooks/CoinjoinPrecondition.test.tsx
@@ -0,0 +1,112 @@
+import React from 'react'
+import { render } from '../testUtils'
+import { act } from 'react-dom/test-utils'
+
+import {
+  COINJOIN_PRECONDITIONS,
+  CoinjoinPreconditionSummary,
+  useCoinjoinPreconditionSummary,
+} from './CoinjoinPrecondition'
+import { Utxos, Utxo } from '../context/WalletContext'
+
+describe('useCoinjoinPreconditionSummary', () => {
+  function setup(utxos: Utxos) {
+    const returnVal: { data: CoinjoinPreconditionSummary | undefined } = { data: undefined }
+    const TestComponent: React.FunctionComponent = () => {
+      returnVal.data = useCoinjoinPreconditionSummary(utxos)
+      return <></>
+    }
+
+    render(<TestComponent />)
+
+    return returnVal
+  }
+
+  it('should NOT be fulfilled on empty utxos', () => {
+    let preconditionSummary: CoinjoinPreconditionSummary | undefined
+
+    act(() => {
+      preconditionSummary = setup([]).data
+    })
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: COINJOIN_PRECONDITIONS.MIN_NUMBER_OF_UTXOS,
+      amountOfMissingConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
+      amountOfMissingOverallRetries: COINJOIN_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES,
+    })
+  })
+
+  it('should be fulfilled on suitable utxos', () => {
+    let preconditionSummary: CoinjoinPreconditionSummary | undefined
+
+    act(() => {
+      preconditionSummary = setup([
+        {
+          frozen: false,
+          confirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
+          tries_remaining: COINJOIN_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES,
+        } as Utxo,
+      ]).data
+    })
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: true,
+      numberOfMissingUtxos: 0,
+      amountOfMissingConfirmations: 0,
+      amountOfMissingOverallRetries: 0,
+    })
+  })
+
+  it('should NOT be fulfilled on utxos with to little confirmations', () => {
+    let preconditionSummary: CoinjoinPreconditionSummary | undefined
+
+    act(() => {
+      preconditionSummary = setup([
+        {
+          frozen: false,
+          confirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO - 1,
+          tries_remaining: COINJOIN_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES,
+        } as Utxo,
+        {
+          frozen: false,
+          confirmations: 0,
+          tries_remaining: COINJOIN_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES,
+        } as Utxo,
+      ]).data
+    })
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: 0,
+      amountOfMissingConfirmations: 1,
+      amountOfMissingOverallRetries: 0,
+    })
+  })
+
+  it('should NOT be fulfilled on utxos with to little remaining retries', () => {
+    let preconditionSummary: CoinjoinPreconditionSummary | undefined
+
+    act(() => {
+      preconditionSummary = setup([
+        {
+          frozen: false,
+          confirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
+          tries_remaining: COINJOIN_PRECONDITIONS.MIN_OVERALL_REMAINING_RETRIES - 1,
+        } as Utxo,
+        {
+          frozen: false,
+          confirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO,
+          tries_remaining: 0,
+        } as Utxo,
+      ]).data
+    })
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: 0,
+      amountOfMissingConfirmations: 0,
+      amountOfMissingOverallRetries: 1,
+    })
+  })
+})