Repository: joinmarket-webui/joinmarket-webui (js)

Issue:
Check sufficient utxo confirmations exist before submitting send request 
I tried sending funds with collaberative transaction enabled. 
The interface indicated that a CoinJoin was in progress...
After some time, the 'Joining...' status indicator disapeared from the dashboard.
The CoinJoin was not successful.
After some time, I realised it was most likely because the UTXO being spent was still unconfirmed.

My suggestion is to add a check, prior to processing the send request, to verify that the UTXO(s) being spent have sufficient confirmations.
 

Pull request:
fix: Check preconditions before send request
Closes #155.

Before this commit, the `Send` page did not validate minimal preconditions that must be met before executing a collaborative transaction. Only the `Jam` page (invoking the scheduler, hence multiple collaborative transactions) displays a warning with further information.

In this commit, the minimal preconditions currently in place (min. 1 spendable utxo [not frozen, not timelocked], min. 5 confirmations of at least one utxo, at least 1 retry left) are externalized to `hooks/CoinjoinPrecondition.tsx`. These prerequisites are currently not very well verified and tested, and can probably be improved a lot – this _is not part_ of this PR.

After this commit these preconditions are also used on the `Send` page when a collaborative transaction is attempted, and if not met, a warning is displayed and the submit button disabled.

Currently `Send` and `Jam` will show different messages, maybe the wording can be harmonized to reuse the warning text across all components in a follow-up PR.

## :camera_flash: 

<img src="https://user-images.githubusercontent.com/3358649/175949581-0fe0133d-255c-406c-895d-36cc1c6fe398.png" width=400 />




Patch:
diff --git a/public/sprite.svg b/public/sprite.svg
index 6cf283d..b96d804 100644
--- a/public/sprite.svg
+++ b/public/sprite.svg
@@ -245,4 +245,8 @@
         <line x1="10.6642" y1="10.75" x2="20.5637" y2="20.6495" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
         <line x1="20.5" y1="10.9142" x2="10.6005" y2="20.8137" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
     </symbol>
+    <symbol id="refresh" fill="currentColor" viewBox="0 0 24 24" >
+        <path fill-rule="evenodd" d="M6.64 9.788a.75.75 0 01.53.918 5 5 0 007.33 5.624.75.75 0 11.75 1.3 6.501 6.501 0 01-9.529-7.312.75.75 0 01.919-.53zM8.75 6.37a6.5 6.5 0 019.529 7.312.75.75 0 11-1.45-.388A5.001 5.001 0 009.5 7.67a.75.75 0 11-.75-1.3z" clip-rule="evenodd" />
+        <path fill-rule="evenodd" d="M5.72 9.47a.75.75 0 011.06 0l2.5 2.5a.75.75 0 11-1.06 1.06l-1.97-1.97-1.97 1.97a.75.75 0 01-1.06-1.06l2.5-2.5zM14.72 10.97a.75.75 0 011.06 0l1.97 1.97 1.97-1.97a.75.75 0 111.06 1.06l-2.5 2.5a.75.75 0 01-1.06 0l-2.5-2.5a.75.75 0 010-1.06z" clip-rule="evenodd" />
+    </symbol>
 </svg>
diff --git a/src/components/App.jsx b/src/components/App.jsx
index b09f31f..34b160f 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useCallback } 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'
@@ -131,9 +130,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/Receive.jsx b/src/components/Receive.jsx
index 46036e5..d2623dc 100644
--- a/src/components/Receive.jsx
+++ b/src/components/Receive.jsx
@@ -140,16 +140,23 @@ export default function Receive() {
               )}
               <rb.Form.Group controlId="amountSats">
                 <rb.Form.Label>{t('receive.label_amount')}</rb.Form.Label>
-                <rb.Form.Control
-                  className="slashed-zeroes"
-                  name="amount"
-                  type="number"
-                  placeholder="0"
-                  value={amount}
-                  min={0}
-                  onChange={(e) => setAmount(e.target.value)}
-                  disabled={isLoading}
-                />
+                <rb.InputGroup>
+                  <rb.InputGroup.Text id="amountSats-addon1" className={styles.inputGroupText}>
+                    <Sprite symbol="sats" width="24" height="24" />
+                  </rb.InputGroup.Text>
+                  <rb.Form.Control
+                    aria-label={t('receive.label_amount')}
+                    className="slashed-zeroes"
+                    name="amount"
+                    type="number"
+                    placeholder="0"
+                    value={amount}
+                    disabled={isLoading}
+                    onChange={(e) => setAmount(e.target.value)}
+                    min={0}
+                    step={1}
+                  />
+                </rb.InputGroup>
                 <rb.Form.Control.Feedback type="invalid">
                   {t('receive.feedback_invalid_amount')}
                 </rb.Form.Control.Feedback>
@@ -158,16 +165,27 @@ export default function Receive() {
           )}
           <hr />
         </div>
-        <rb.Button variant="outline-dark" type="submit" disabled={isLoading} className="mt-2" style={{ width: '100%' }}>
-          {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" />
-              {t('receive.text_getting_address')}
-            </div>
-          ) : (
-            t('receive.button_new_address')
-          )}
-        </rb.Button>
+
+        <div className="mt-2 d-flex justify-content-center">
+          <rb.Button
+            variant="outline-dark"
+            type="submit"
+            disabled={isLoading}
+            className="d-flex justify-content-center align-items-center"
+          >
+            {isLoading ? (
+              <>
+                <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
+                {t('receive.text_getting_address')}
+              </>
+            ) : (
+              <>
+                <Sprite symbol="refresh" className="me-2" width="24" height="24" />
+                {t('receive.button_new_address')}
+              </>
+            )}
+          </rb.Button>
+        </div>
       </rb.Form>
     </div>
   )
diff --git a/src/components/Receive.module.css b/src/components/Receive.module.css
index 320a0b4..0871a0e 100644
--- a/src/components/Receive.module.css
+++ b/src/components/Receive.module.css
@@ -44,6 +44,14 @@
   height: 1.5rem;
 }
 
+.inputGroupText {
+  width: 5ch;
+  display: inline-flex;
+  justify-content: center;
+  align-items: center;
+  font-size: 1.2rem;
+}
+
 .qr-container {
   display: flex;
   justify-content: center;
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/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/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,
+    })
+  })
+})