Repository: joinmarket-webui/jam (ts)

Issue:
Verify and fix preconditions for collaborative transactions
The current preconditions for collaborative transactions are flawed.

As of `v0.0.9`, [ `CoinjoinPreconditions` are as follows](https://github.com/joinmarket-webui/jam/blob/v0.0.9/src/hooks/CoinjoinPrecondition.ts#L5-L10):
```
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
}
```

This is not representing reality in jm, as can be seen in the logs below.
e.g. from the logs it seems that _more than one_ utxos need at least 5 confirmations, whereas Jam incorrectly states it needs **at least one** utxo with 5 confirmations. <- this statement needs verification - the approach might still be valid "per mixdepth"!

There might be other conditions that are not yet represented correctly and must be investigated.

The following log stems from starting the Scheduler (Jam page) with two confirmed utxos (>5 confs) and one unconfirmed utxo in Jar#0 and a single unconfirmed utxo in Jar #4:
```
2022-07-19 12:51:32,118 [INFO]  INFO:Failed to source a commitment; this debugging information may help:

1: Utxos that passed age and size limits, but have been used too many times (see taker_utxo_retries in the config):
None
2: Utxos that have less than 5 confirmations:
7c9c7341aea0306d0c20e50d416bcf1a8ebda74d241c7cce5a6a115e7d08eefb:2
3: Utxos that were not at least 20% of the size of the coinjoin amount 123119103
None
***
Utxos that appeared in item 1 cannot be used again.
Utxos only in item 2 can be used by waiting for more confirmations, (set by the value of taker_utxo_age).
Utxos only in item 3 are not big enough for this coinjoin transaction, set by the value of taker_utxo_amtpercent.
If you cannot source a utxo from your spending mixdepth according to these rules, use the tool add-utxo.py to source a utxo from another mixdepth or a utxo external to your joinmarket wallet. Read the help with 'python add-utxo.py --help'

***
For reference, here are the utxos in your wallet:

mixdepth 0:
    c2d9725bef8450b3fd3effbbee3b6b74222767c23f5588448fb7b11fd4494587:0 - path: m/84'/1'/0'/0/0, address: bcrt1q05c2hcn8jrl0l2mymegj5zwgzcjzway73ght2n, value: 5000000000
    2f510976e4ebf0bd3cb0c11b6bc5b6c969e351e26894a0ab44490651dd62558c:0 - path: m/84'/1'/0'/0/0, address: bcrt1q05c2hcn8jrl0l2mymegj5zwgzcjzway73ght2n, value: 5000000000
    7c9c7341aea0306d0c20e50d416bcf1a8ebda74d241c7cce5a6a115e7d08eefb:0 - path: m/84'/1'/0'/1/0, address: bcrt1qenkhn0nk5pzsa6aca4eyzwuz3z8pu8nlheevd2, value: 4876873907
mixdepth 4:
    7c9c7341aea0306d0c20e50d416bcf1a8ebda74d241c7cce5a6a115e7d08eefb:2 - path: m/84'/1'/4'/0/0, address: bcrt1qvzzhwmvzchghrtka2ghrky3kksh3hn2j0w0j56, value: 123123123
```

Pull request:
fix: precondition for collaborative transactions
Closes #438.

Adds `CoinjoinRequirements` as replacement for `CoinjoinPreconditions`.

Changes:
- minimum confirmations limit (>=5 confs) must be fulfilled for all given utxos (instead of single utxo)
  - for single collaborative tx: min conf limit for utxos in source jar
  - for scheduler: min conf limit for utxos in _all_ jars
- at least one retry must be available
  - for single collaborative tx: at least one retry left in source jar
  - for scheduler:  at least one retry left in _all_ (funded) jars
- **do not disable** the "Start Scheduler" button on the Sweep page when displaying "precondition warnings"
  - with latest upstream changes, the scheduler will wait and retry e.g. if utxos with less than 5 confs are present
  - also, if no retries are left, the scheduler might rectify this situation (sometimes, not always)
  - Currently, the "Send" button on the send page is still disabled, as there are no retry mechanisms in place. The source jar is known so there should be no false positives.

Regtest:
- regtest: set `taker_utxo_retries` and `maker_timeout_sec` config vars for improved testability
  -  default of `taker_utxo_retries` is `3` - reduce to `1` to easily "simulate" a jar without retries left
  - default of  `maker_timeout_sec` is `30` - making it `10` will increase chances of a maker timeout and **additionally** invokes the "Stall monitor" more often

Tests:
- [x]  3 utxos with >5 confs in Jar A: Success.
- [x]  3 utxos with >5 confs distributed in Jar A, Jar B and Jar C: Success.
- [x]  2 utxos with >5 confs and 1 utxo with 1conf in Jar A, 1 utxo with 1conf in Jar B: Fail -> fixed
- [x]  3 utxos with >5 confs in Jar A, 1 utxo with 0conf in Jar B: Fail -> fixed
- [x]  2 utxos with >5 confs in Jar A, 1 utxo with >5confs in Jar B, 1 utxo with 4 confs in Jar E: Fail: Fail -> fixed

<img width=400 src="https://user-images.githubusercontent.com/3358649/189659086-0dabdb48-dc37-41ee-ae20-f926806c1e18.png" />

- [x]  2 utxos with >5 confs in Jar A, 2 utxo (one without retries left) in Jar B: Success!

Additional tests after https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1347 has been merged:
- [x] Scheduler continuing (with orderbook being initially empty) after an order was broadcasted after starting
- [x] 2 utxos with >5 confs in Jar A, 1 utxo without retries left in Jar B: Fail -> fixed

<img width=400 src="https://user-images.githubusercontent.com/3358649/189674687-17d8bed0-1108-4697-beb6-0d8e3bded272.png" />
(slightly outdated screenshot - the info is displayed as warning (yellow) instead of an error (red))

### How to test
#### Minimum value requirement
Not implemented - reasons stated below

#### Minimum confirmation requirement (>5 confs)
Try using the scheduler or send page with a jar having less then 5 confirmations.

#### No retries left
To just see the "retries left" error message add the following snipped _temporarily_ to the `Jam.jsx` component.
```js
  walletInfo?.data.utxos.utxos.forEach((it) => it.tries_remaining = 0)
```

To really emulate and test having a utxo without retries left, you need to manually abort a taker operation after a commitment has been sourced. To do this you can send a collaborative transaction from one jar to another, while observing the logs (`npm run regtest:logs:jmwalletd`) and immediately lock the wallet once you see the log message `INFO:Commitment sourced OK`. You can test various scenarios, e.g. having only one utxo without retries left (tx will fail - message should appear) or having 1 utxo **without** retries left + 1 utxo **with** retries left (tx can succeed - message should NOT appear). 

The message should only appear if all utxos in a jar have no retries left.

---

Not addressed:
- Utxos that were not at least 20% of the size of the coinjoin amount
  - Could not reproduce this behaviour consistently (e.g. single collab tx with input 1rBTC and 2500rsats was successful) 
  - Difficult to reproduce as utxos are prefiltered by JM and it is not trivial to rebuild this behaviour client-side
  - Furthermore, would need a way to get the actual tx amount during the scheduler reliably

e.g. sending 30.1rBTC (3010000000 sats) with two utxos (30rBTC and 0.12345678rBTC) was successful
```
2022-09-10 17:46:40,443 [DEBUG]  schedule item was: [4, 3010000000, 1, 'bcrt1q9dwpt0x33k8a7nfdrpej9lfsmk7q4xquxkgas7', 0, 16, 0]
[...]
2022-09-10 17:46:44,310 [INFO]  Removed utxos=
d11fc2f089d672b4a7d2784062b5b343315dcdd43894fa508d8d5a1356631d90:1 - path: m/84'/1'/4'/0/12, address: bcrt1qtn5jlr74lfg4cf5pcvgchytt4dkn3yq3205ntp, value: 12345678
4af2681b928893045eba143b6c08d83248527d0d52e9e7ed3b5f186c36e630da:0 - path: m/84'/1'/4'/0/11, address: bcrt1q6nwjgnf6qg4yejytxnyncf9lm0glxzttwusv2d, value: 3000000000
```
sweeping three utxos (~76rBTC, ~21rBTC and 0.02341166rBTC) was also successful 
```
2022-09-10 17:58:24,873 [DEBUG]  schedule item was: [0, 0, 1, 'bcrt1qfqe4kkm940vx8fm0jcycu44tfktmtw38ga7ld4', 0, 16, 0]
[...]
2022-09-10 17:58:29,281 [INFO]  Removed utxos=
b3b57af79255753a7798133738942e511a2fda09a26822317ad98cea1462b749:0 - path: m/84'/1'/0'/0/11, address: bcrt1q79ne2hnuxv47pz078rfnq39n26l0u65utr5433, value: 2341166
c58b15e3f09c4f2842449673ea4e3b5284a7fbfb6ea5c2486c57b1a98975e192:0 - path: m/84'/1'/0'/0/9, address: bcrt1q37jxrr28tf8kerx4hs8d9ev6rad2mxglylcfd9, value: 7616114702
9f1fbb728ba994083e37bbf20acd4c6789b00d6a77a4f0a0bd52738163181948:0 - path: m/84'/1'/0'/0/10, address: bcrt1qyce0vz6zfkkds7t875u0dxwehwf3t3gu8a3gq9, value: 2124668118
````

However, the following (with a sweep) did **not** succeed:
```
2022-09-10 18:18:52,572 [DEBUG]  cj amount = 2246731806
2022-09-10 18:18:52,594 [INFO]  ABORT:Failed to source a commitment; this debugging information may help:

1: Utxos that passed age and size limits, but have been used too many times (see taker_utxo_retries in the config):
d11fc2f089d672b4a7d2784062b5b343315dcdd43894fa508d8d5a1356631d90:0
2: Utxos that have less than 5 confirmations:
None
3: Utxos that were not at least 20% of the size of the coinjoin amount 2246731806
165c596ab9711b46536364182725167a1d912efee0630c9d8fe095552bcf098b:0
***
Utxos that appeared in item 1 cannot be used again.
Utxos only in item 2 can be used by waiting for more confirmations, (set by the value of taker_utxo_age).
Utxos only in item 3 are not big enough for this coinjoin transaction, set by the value of taker_utxo_amtpercent.
If you cannot source a utxo from your spending mixdepth according to these rules, use the tool add-utxo.py to source a utxo from another mixdepth or a utxo external to your joinmarket wallet. Read the help with 'python add-utxo.py --help'

***
For reference, here are the utxos in your wallet:

mixdepth 4:
    be817dee9eafb9199609109b7f36e608aa6164f70cd57446a88b26d4b043b514:2 - path: m/84'/1'/4'/0/13, address: bcrt1qfqe4kkm940vx8fm0jcycu44tfktmtw38ga7ld4, value: 9743118854
mixdepth 3:
    2e8316092b08eccf353e2754199084610b25173dff98a2c1b354b57d058b3127:2 - path: m/84'/1'/3'/0/5, address: bcrt1q9dwpt0x33k8a7nfdrpej9lfsmk7q4xquxkgas7, value: 3010000000
mixdepth 1:
    d11fc2f089d672b4a7d2784062b5b343315dcdd43894fa508d8d5a1356631d90:0 - path: m/84'/1'/1'/1/11, address: bcrt1q438f9hg99p8edhafdmafremrpy69sfqh3fxk47, value: 2073019140
    165c596ab9711b46536364182725167a1d912efee0630c9d8fe095552bcf098b:0 - path: m/84'/1'/1'/0/3, address: bcrt1qsvj777cuq5de65grj4x6k3uyxrrnwxursx98ky, value: 173717118
```
BUT, if the same jar is sourced with a new utxo and swept, it will succeed:
````
2022-09-12 18:27:47,614 [DEBUG]  cj amount = 11989848869
[...]
2022-09-10 18:27:56,922 [DEBUG]  schedule item was: [1, 0, 1, 'bcrt1qd60sjmf25mj3ze8kc9sa5lcs7tp692hr2g0j5s', 0, 16, 0]
[...]
2022-09-10 18:27:58,048 [INFO]  Removed utxos=
3cbee223000ee40195706acb080e427988cb76c3befc207c61adfa6157a78688:0 - path: m/84'/1'/1'/0/4, address: bcrt1qjzuaucz8zd3d30xxauygxrxv03kajkaaalhjcw, value: 9743117744
165c596ab9711b46536364182725167a1d912efee0630c9d8fe095552bcf098b:0 - path: m/84'/1'/1'/0/3, address: bcrt1qsvj777cuq5de65grj4x6k3uyxrrnwxursx98ky, value: 173717118
d11fc2f089d672b4a7d2784062b5b343315dcdd43894fa508d8d5a1356631d90:0 - path: m/84'/1'/1'/1/11, address: bcrt1q438f9hg99p8edhafdmafremrpy69sfqh3fxk47, value: 2073019140
```

Patch:
diff --git a/src/components/CoinjoinPreconditionViolationAlert.tsx b/src/components/CoinjoinPreconditionViolationAlert.tsx
new file mode 100644
index 0000000..d2ea2b2
--- /dev/null
+++ b/src/components/CoinjoinPreconditionViolationAlert.tsx
@@ -0,0 +1,100 @@
+import React, { forwardRef } from 'react'
+import * as rb from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import { useSettings } from '../context/SettingsContext'
+import { CoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
+import { jarInitial } from './jars/Jar'
+import { shortenStringMiddle } from '../utils'
+import Sprite from './Sprite'
+import Balance from './Balance'
+
+interface CoinjoinPreconditionViolationAlertProps {
+  summary: CoinjoinRequirementSummary
+  i18nPrefix?: string
+}
+
+export const CoinjoinPreconditionViolationAlert = forwardRef(
+  ({ summary, i18nPrefix = '' }: CoinjoinPreconditionViolationAlertProps, ref: React.Ref<HTMLDivElement>) => {
+    const { t } = useTranslation()
+    const settings = useSettings()
+
+    if (summary.isFulfilled) return <></>
+
+    if (summary.numberOfMissingUtxos > 0) {
+      return (
+        <rb.Alert variant="warning" ref={ref}>
+          {t(`${i18nPrefix}hint_missing_utxos`, {
+            minConfirmations: summary.options.minConfirmations,
+          })}
+        </rb.Alert>
+      )
+    }
+
+    if (summary.numberOfMissingConfirmations > 0) {
+      return (
+        <rb.Alert variant="warning" ref={ref}>
+          {t(`${i18nPrefix}hint_missing_confirmations`, {
+            minConfirmations: summary.options.minConfirmations,
+            amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
+          })}
+        </rb.Alert>
+      )
+    }
+
+    const utxosViolatingRetriesLeft = summary.violations
+      .map((it) => it.utxosViolatingRetriesLeft)
+      .reduce((acc, utxos) => acc.concat(utxos), [])
+
+    if (utxosViolatingRetriesLeft.length > 0) {
+      return (
+        <rb.Alert variant="warning" ref={ref}>
+          <>
+            <Trans i18nKey={`${i18nPrefix}hint_missing_retries`}>
+              You tried too many times. See
+              <a
+                href="https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.7/docs/SOURCING-COMMITMENTS.md"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                the docs
+              </a>{' '}
+              for more info.
+            </Trans>
+            <br />
+            <br />
+            <Trans i18nKey={`${i18nPrefix}hint_missing_retries_detail`} count={utxosViolatingRetriesLeft.length}>
+              Following utxos have been used unsuccessfully too many times:
+              <ul className="mt-2 mb-0 ps-2">
+                {utxosViolatingRetriesLeft.map((utxo, index) => (
+                  <li key={index} className="mb-2 slashed-zeroes small" style={{ display: 'inline-flex' }}>
+                    <span className="pe-1" style={{ display: 'inline-flex' }}>
+                      <Sprite symbol="jar-closed-fill-50" width="20" height="20" />
+                      <span className="slashed-zeroes">
+                        <strong>{jarInitial(utxo.mixdepth)}</strong>
+                      </span>
+                      :
+                    </span>
+                    <div>
+                      <span>{utxo.address}</span>
+                      &nbsp;(
+                      <Balance
+                        valueString={`${utxo.value}`}
+                        convertToUnit={settings.unit}
+                        showBalance={settings.showBalance}
+                      />
+                      )
+                      <br />
+                      <small>{shortenStringMiddle(utxo.utxo, 32)}</small>
+                    </div>
+                  </li>
+                ))}
+              </ul>
+            </Trans>
+          </>
+        </rb.Alert>
+      )
+    }
+
+    return <></>
+  }
+)
diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx
index de7f26a..af344e8 100644
--- a/src/components/Jam.jsx
+++ b/src/components/Jam.jsx
@@ -1,13 +1,14 @@
 import React, { useState, useEffect, useCallback, useMemo } from 'react'
 import * as rb from 'react-bootstrap'
-import { Trans, useTranslation } from 'react-i18next'
+import { useTranslation } from 'react-i18next'
 import { Formik, useFormikContext } from 'formik'
 import * as Api from '../libs/JmWalletApi'
 import { useSettings } from '../context/SettingsContext'
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useCurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { isDebugFeatureEnabled } from '../constants/debugFeatures'
-import { COINJOIN_PRECONDITIONS, useCoinjoinPreconditionSummary } from '../hooks/CoinjoinPrecondition'
+import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
+import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
 import PageTitle from './PageTitle'
 import ToggleSwitch from './ToggleSwitch'
 import Sprite from './Sprite'
@@ -21,8 +22,6 @@ import styles from './Jam.module.css'
 // Length of this array must be 3 for now.
 const INTERNAL_DEST_ACCOUNTS = [0, 1, 2]
 
-const SCHEDULER_START_ACCOUNT = 0
-
 const ValuesListener = ({ handler }) => {
   const { values } = useFormikContext()
 
@@ -48,16 +47,9 @@ export default function Jam() {
   const [isLoading, setIsLoading] = useState(true)
   const [collaborativeOperationRunning, setCollaborativeOperationRunning] = useState(false)
 
-  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]
+  const schedulerPreconditionSummary = useMemo(
+    () => buildCoinjoinRequirementSummary(walletInfo?.data.utxos.utxos || []),
+    [walletInfo]
   )
 
   // Returns one fresh address for each requested mixdepth.
@@ -135,7 +127,7 @@ export default function Jam() {
   }, [serviceInfo])
 
   const startSchedule = async (values) => {
-    if (isLoading || collaborativeOperationRunning || !isSchedulerPreconditionsFulfilled) {
+    if (isLoading || collaborativeOperationRunning) {
       return
     }
 
@@ -219,54 +211,15 @@ export default function Jam() {
             </rb.Alert>
           )}
           <rb.Fade
-            in={!collaborativeOperationRunning && !isSchedulerPreconditionsFulfilled}
+            in={!collaborativeOperationRunning && !schedulerPreconditionSummary.isFulfilled}
             mountOnEnter={true}
             unmountOnExit={true}
+            className="mb-4"
           >
-            <rb.Alert variant="warning" className="mb-4">
-              <>
-                {schedulerPreconditionSummary.numberOfMissingUtxos > 0 ? (
-                  <Trans i18nKey="scheduler.precondition.hint_missing_utxos">
-                    To run the scheduler you need at least one UTXO with{' '}
-                    <strong>{{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
-                    confirmations. Fund your wallet and wait for{' '}
-                    <strong>{{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}</strong>{' '}
-                    blocks.
-                  </Trans>
-                ) : schedulerPreconditionSummary.amountOfMissingConfirmations > 0 ? (
-                  <Trans i18nKey="scheduler.precondition.hint_missing_confirmations">
-                    The scheduler 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. Wait for{' '}
-                    <strong>
-                      {{ amountOfMissingConfirmations: schedulerPreconditionSummary.amountOfMissingConfirmations }}
-                    </strong>{' '}
-                    more block(s).
-                  </Trans>
-                ) : (
-                  schedulerPreconditionSummary.amountOfMissingOverallRetries > 0 && (
-                    <Trans i18nKey="scheduler.precondition.hint_missing_overall_retries">
-                      You've tried running the scheduler 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>
+            <CoinjoinPreconditionViolationAlert
+              summary={schedulerPreconditionSummary}
+              i18nPrefix="scheduler.precondition."
+            />
           </rb.Fade>
           {!collaborativeOperationRunning && walletInfo && (
             <>
@@ -421,11 +374,7 @@ export default function Jam() {
                       className={styles.submit}
                       variant="dark"
                       type="submit"
-                      disabled={
-                        isSubmitting ||
-                        isLoading ||
-                        (!collaborativeOperationRunning && (!isValid || !isSchedulerPreconditionsFulfilled))
-                      }
+                      disabled={isSubmitting || isLoading || (!collaborativeOperationRunning && !isValid)}
                     >
                       <div className="d-flex justify-content-center align-items-center">
                         {collaborativeOperationRunning ? t('scheduler.button_stop') : t('scheduler.button_start')}
diff --git a/src/components/Send.jsx b/src/components/Send.jsx
index af68194..fc26e6a 100644
--- a/src/components/Send.jsx
+++ b/src/components/Send.jsx
@@ -13,13 +13,14 @@ import { useReloadCurrentWalletInfo, useCurrentWallet, useCurrentWalletInfo } fr
 import { useServiceInfo, useReloadServiceInfo } from '../context/ServiceInfoContext'
 import { useLoadConfigValue } from '../context/ServiceConfigContext'
 import { useSettings } from '../context/SettingsContext'
-import { COINJOIN_PRECONDITIONS, useCoinjoinPreconditionSummary } from '../hooks/CoinjoinPrecondition'
+import { buildCoinjoinRequirementSummary } from '../hooks/CoinjoinRequirements'
 
 import * as Api from '../libs/JmWalletApi'
 import { SATS, formatBtc, formatSats } from '../utils'
 import { routes } from '../constants/routes'
 import styles from './Send.module.css'
 import { ConfirmModal } from './Modal'
+import { CoinjoinPreconditionViolationAlert } from './CoinjoinPreconditionViolationAlert'
 import { jarInitial, jarName } from './jars/Jar'
 
 const IS_COINJOIN_DEFAULT_VAL = true
@@ -213,54 +214,6 @@ 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()
@@ -324,7 +277,10 @@ export default function Send() {
     return walletInfo.data.utxos.utxos.filter((it) => it.mixdepth === account)
   }, [walletInfo, account])
 
-  const coinjoinPreconditionSummary = useCoinjoinPreconditionSummary(sourceJarUtxos || [])
+  const coinjoinPreconditionSummary = useMemo(
+    () => buildCoinjoinRequirementSummary(sourceJarUtxos || []),
+    [sourceJarUtxos]
+  )
 
   useEffect(() => {
     if (
@@ -717,7 +673,12 @@ export default function Send() {
         )}
 
         {!isLoading && !isOperationDisabled && isCoinjoin && !coinjoinPreconditionSummary.isFulfilled && (
-          <CoinjoinPreconditionFailedAlert coinjoinPreconditionSummary={coinjoinPreconditionSummary} />
+          <div className="mb-4">
+            <CoinjoinPreconditionViolationAlert
+              summary={coinjoinPreconditionSummary}
+              i18nPrefix="send.coinjoin_precondition."
+            />
+          </div>
         )}
 
         {!isLoading && walletInfo && (
diff --git a/src/components/fb/CreateFidelityBond.jsx b/src/components/fb/CreateFidelityBond.jsx
index c3ec908..f3a01b2 100644
--- a/src/components/fb/CreateFidelityBond.jsx
+++ b/src/components/fb/CreateFidelityBond.jsx
@@ -265,7 +265,7 @@ const CreateFidelityBond = ({ otherFidelityBondExists, accountBalances, totalBal
         }
 
         if (timelockedAddress === null) {
-          return <div>{t('earn.fidelity_bond.error.loading_address')}</div>
+          return <div>{t('earn.fidelity_bond.error_loading_address')}</div>
         }
 
         return (
diff --git a/src/hooks/CoinjoinPrecondition.ts b/src/hooks/CoinjoinPrecondition.ts
deleted file mode 100644
index 4c2d47d..0000000
--- a/src/hooks/CoinjoinPrecondition.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useMemo } from 'react'
-import * as fb from '../components/fb/utils'
-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) => !fb.utxo.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/hooks/CoinjoinRequirements.ts b/src/hooks/CoinjoinRequirements.ts
new file mode 100644
index 0000000..0edb3f2
--- /dev/null
+++ b/src/hooks/CoinjoinRequirements.ts
@@ -0,0 +1,116 @@
+import * as fb from '../components/fb/utils'
+import { Utxos } from '../context/WalletContext'
+
+export type CoinjoinRequirementOptions = {
+  minNumberOfUtxos: number // min amount of utxos available
+  // https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.7/docs/SOURCING-COMMITMENTS.md#wait-for-at-least-5-confirmations
+  minConfirmations: number // all utxos needs X confirmations
+}
+
+export const DEFAULT_REQUIREMENT_OPTIONS: CoinjoinRequirementOptions = {
+  minNumberOfUtxos: 1,
+  minConfirmations: 5, // default of `taker_utxo_age` in jm config
+}
+
+export interface CoinjoinRequirementViolation {
+  hasViolations: boolean
+  utxosViolatingRetriesLeft: Utxos
+  utxosViolatingMinConfirmations: Utxos
+}
+
+export type CoinjoinRequirementViolationWithJarIndex = { jarIndex: number } & CoinjoinRequirementViolation
+
+export interface CoinjoinRequirementSummary {
+  isFulfilled: boolean
+  options: CoinjoinRequirementOptions
+  numberOfMissingUtxos: number
+  numberOfMissingConfirmations: number
+  violations: CoinjoinRequirementViolationWithJarIndex[]
+}
+
+const filterEligibleUtxos = (utxos: Utxos) => {
+  return utxos.filter((it) => !it.frozen).filter((it) => !fb.utxo.isLocked(it))
+}
+
+const filterUtxosViolatingMinConfirmationRequirement = (utxos: Utxos, minConfirmation: number) => {
+  return utxos.filter((it) => it.confirmations < minConfirmation)
+}
+
+const filterUtxosViolatingTriesLeftRequirement = (utxos: Utxos) => {
+  const utxoWithoutRetriesLeft = utxos.filter((it) => it.tries_remaining === 0)
+  const retriesAvailable = utxoWithoutRetriesLeft.length < utxos.length
+  // if at least one try is still available, the requirement is not violated (yet)
+  return retriesAvailable ? [] : utxoWithoutRetriesLeft
+}
+
+const buildCoinjoinViolationSummaryForJar = (
+  utxos: Utxos,
+  options: CoinjoinRequirementOptions
+): CoinjoinRequirementViolation => {
+  const eligibleUtxos = filterEligibleUtxos(utxos)
+  const utxosViolatingRetriesLeft = filterUtxosViolatingTriesLeftRequirement(eligibleUtxos)
+  const utxosViolatingMinConfirmations = filterUtxosViolatingMinConfirmationRequirement(
+    eligibleUtxos,
+    options.minConfirmations
+  )
+
+  const hasViolations = utxosViolatingRetriesLeft.length > 0 || utxosViolatingMinConfirmations.length > 0
+
+  return {
+    hasViolations,
+    utxosViolatingRetriesLeft,
+    utxosViolatingMinConfirmations,
+  }
+}
+
+type UtxosByJar = { [key: number]: Utxos }
+
+const groupByJar = (utxos: Utxos): UtxosByJar => {
+  return utxos.reduce((res, utxo) => {
+    const { mixdepth } = utxo
+    res[mixdepth] = res[mixdepth] || []
+    res[mixdepth].push(utxo)
+    return res
+  }, {} as UtxosByJar)
+}
+
+export const buildCoinjoinRequirementSummary = (
+  utxos: Utxos,
+  options = DEFAULT_REQUIREMENT_OPTIONS
+): CoinjoinRequirementSummary => {
+  const eligibleUtxos = filterEligibleUtxos(utxos)
+  const utxosByJars = groupByJar(eligibleUtxos)
+
+  const violations: CoinjoinRequirementViolationWithJarIndex[] = []
+
+  for (const jarIndex in utxosByJars) {
+    const violationsByJar = buildCoinjoinViolationSummaryForJar(utxosByJars[jarIndex], options)
+    if (violationsByJar.hasViolations) {
+      violations.push({ jarIndex: +jarIndex, ...violationsByJar })
+    }
+  }
+
+  const lowestConfInWallet = violations
+    .filter((it) => it.utxosViolatingMinConfirmations.length > 0)
+    .map((it) =>
+      it.utxosViolatingMinConfirmations.reduce(
+        (acc, utxo) => Math.min(acc, utxo.confirmations),
+        Number.MAX_SAFE_INTEGER
+      )
+    )
+    .reduce((acc, lowestConfPerJar) => Math.min(acc, lowestConfPerJar), Number.MAX_SAFE_INTEGER)
+
+  const numberOfMissingConfirmations = Math.max(0, options.minConfirmations - lowestConfInWallet)
+
+  const numberOfMissingUtxos = Math.max(0, options.minNumberOfUtxos - eligibleUtxos.length)
+
+  const isFulfilled = numberOfMissingUtxos === 0 && numberOfMissingConfirmations === 0 && violations.length === 0
+
+  return {
+    isFulfilled,
+    options,
+    numberOfMissingUtxos,
+    numberOfMissingConfirmations,
+    violations,
+  }
+}
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 7f8e6df..d8aa496 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -221,9 +221,15 @@
       "text_collaborative_tx_disabled": "Payment without privacy improvement"
     },
     "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."
+      "hint_missing_utxos": "To execute a collaborative transaction you need UTXOs with {{ minConfirmations }} or more confirmations in the source jar. $t(send.coinjoin_precondition.nested_hint_fund_jar, {\"count\": {{ minConfirmations }} })",
+      "hint_missing_confirmations": "A collaborative transaction requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(send.coinjoin_precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
+      "hint_missing_retries": "Failed at sourcing commitments for this jar too many times in a row. For security reasons, you need a fresh UTXO in the jar to try again. See <1>the docs</1> for more information.",
+      "hint_missing_retries_detail_one": "The following UTXO has been used unsuccessfully too many times and cannot be reused: <1></1>",
+      "hint_missing_retries_detail_other": "The following {{ count }} UTXOs have been used unsuccessfully too many times and cannot be reused: <1></1>",
+      "nested_hint_wait_for_block": "Select another jar to send from or wait for one more block.",
+      "nested_hint_wait_for_block_other": "Select another jar to send from or wait for {{ count }} more blocks.",
+      "nested_hint_fund_jar": "Select another jar to send from or fund this jar and wait for one block.",
+      "nested_hint_fund_jar_other": "Select another jar to send from or fund this jar and wait for {{ count }} blocks."
     }
   },
   "receive": {
@@ -339,6 +345,7 @@
       "review_inputs": {
         "text_primary_button": "Create fidelity bond",
         "text_primary_button_unsafe": "Create fidelity bond with potentially less private UTXOs",
+        "text_primary_button_error": "Try again",
         "text_secondary_button": "Cancel",
         "description": "You configured the fidelity bond as follows:",
         "label_lock_date": "Locked until",
@@ -453,9 +460,15 @@
     "progress_current_state": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> to process...",
     "progress_done": "All transactions completed successfully. The scheduler will stop soon.",
     "precondition": {
-      "hint_missing_utxos": "To run the scheduler you need at least one UTXO with <2>{{ minConfirmations }}</2> confirmations in Jar A. Fund your wallet and wait for <6>{{ minConfirmations }}</6> blocks.",
-      "hint_missing_confirmations": "The scheduler requires one of your UTXOs in Jar A to have <2>{{ minConfirmations }}</2> or more confirmations. Wait for <6>{{ amountOfMissingConfirmations }}</6> more block(s).",
-      "hint_missing_overall_retries": "You've tried running the scheduler unsuccessfully too many times in a row. For security reasons, you need a fresh UTXO in Jar A to try again. See <2>the docs</2> for more information."
+      "hint_missing_utxos": "To run the scheduler you need UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
+      "hint_missing_confirmations": "The scheduler requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
+      "hint_missing_retries": "Failed at sourcing commitments too many times. For security reasons, you need a fresh UTXO in the jar to try again. See <1>the docs</1> for more information.",
+      "hint_missing_retries_detail_one": "The following UTXO has been used unsuccessfully too many times and cannot be reused: <1></1>",
+      "hint_missing_retries_detail_other": "The following {{ count }} UTXOs have been used unsuccessfully too many times and cannot be reused: <1></1>",
+      "nested_hint_wait_for_block": "Wait for one more block.",
+      "nested_hint_wait_for_block_other": "Wait for {{ count }} more blocks.",
+      "nested_hint_fund_wallet": "Fund your wallet and wait for one more block.",
+      "nested_hint_fund_wallet_other": "Fund your wallet and wait for {{ count }} more blocks."
     }
   },
   "modal": {
diff --git a/src/utils.ts b/src/utils.ts
index 99a6aa5..d353e53 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -38,3 +38,11 @@ export const formatBtc = (value: number) => {
 export const formatSats = (value: number) => {
   return SATS_FORMATTER.format(value)
 }
+
+export const shortenStringMiddle = (value: string, chars = 8, separator = '…') => {
+  const prefixLength = Math.max(Math.floor(chars / 2), 1)
+  if (value.length <= prefixLength * 2) {
+    return `${value}`
+  }
+  return `${value.substring(0, prefixLength)}${separator}${value.substring(value.length - prefixLength)}`
+}


Test patch:
diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml
index 9e26df9..f56c111 100644
--- a/docker/regtest/docker-compose.yml
+++ b/docker/regtest/docker-compose.yml
@@ -63,6 +63,8 @@ services:
       jm_rpc_password: joinmarket2
       jm_directory_nodes: ${JM_DIRECTORY_NODES:?You must set the onion address generated in prepare step to your .env file}
       jm_socks5: "false" # will _not_ connect to local irc over tor
+      jm_taker_utxo_retries: 1 # easier testing of commitment failures on regtest; default is 3
+      maker_timeout_sec: 10 # easier testing of maker timeouts on regtest (and "Stall Monitor" retries); default is 60
     expose:
       - 80    # nginx
       - 62601 # obwatch
diff --git a/src/hooks/CoinjoinPrecondition.test.tsx b/src/hooks/CoinjoinPrecondition.test.tsx
deleted file mode 100644
index 9b59218..0000000
--- a/src/hooks/CoinjoinPrecondition.test.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-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,
-    })
-  })
-})
diff --git a/src/hooks/CoinjoinRequirements.test.ts b/src/hooks/CoinjoinRequirements.test.ts
new file mode 100644
index 0000000..b725616
--- /dev/null
+++ b/src/hooks/CoinjoinRequirements.test.ts
@@ -0,0 +1,215 @@
+import { DEFAULT_REQUIREMENT_OPTIONS, buildCoinjoinRequirementSummary } from './CoinjoinRequirements'
+import { Utxo } from '../context/WalletContext'
+
+describe('CoinjoinRequirements', () => {
+  const defaultOptions = DEFAULT_REQUIREMENT_OPTIONS
+
+  it('should NOT be fulfilled on empty utxos', () => {
+    const preconditionSummary = buildCoinjoinRequirementSummary([])
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: defaultOptions.minNumberOfUtxos,
+      numberOfMissingConfirmations: 0,
+      options: defaultOptions,
+      violations: [],
+    })
+  })
+
+  it('should NOT be fulfilled on missing eligible utxos (e.g. all frozen)', () => {
+    const preconditionSummary = buildCoinjoinRequirementSummary([
+      {
+        frozen: true, // not eligible
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 3,
+      } as Utxo,
+    ])
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: 1,
+      numberOfMissingConfirmations: 0,
+      options: defaultOptions,
+      violations: [],
+    })
+  })
+
+  it('should be fulfilled on suitable utxos', () => {
+    const preconditionSummary = buildCoinjoinRequirementSummary([
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 1,
+      } as Utxo,
+      {
+        frozen: true, // not eligible
+        confirmations: 0,
+        tries_remaining: 0,
+      } as Utxo,
+    ])
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: true,
+      numberOfMissingUtxos: 0,
+      numberOfMissingConfirmations: 0,
+      options: defaultOptions,
+      violations: [],
+    })
+  })
+
+  it('should NOT be fulfilled on utxos with to little confirmations', () => {
+    let preconditionSummary = buildCoinjoinRequirementSummary([
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations - 1,
+        tries_remaining: 1,
+        mixdepth: 0,
+      } as Utxo,
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 1,
+        mixdepth: 0,
+      } as Utxo,
+      {
+        frozen: true, // not eligible
+        confirmations: 0,
+        tries_remaining: 0,
+        mixdepth: 0,
+      } as Utxo,
+    ])
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: 0,
+      numberOfMissingConfirmations: 1,
+      options: defaultOptions,
+      violations: [
+        {
+          hasViolations: true,
+          jarIndex: 0,
+          utxosViolatingMinConfirmations: [
+            {
+              confirmations: defaultOptions.minConfirmations - 1,
+              frozen: false,
+              tries_remaining: 1,
+              mixdepth: 0,
+            },
+          ],
+          utxosViolatingRetriesLeft: [],
+        },
+      ],
+    })
+  })
+
+  it('should NOT be fulfilled on utxos without remaining retries', () => {
+    let preconditionSummary = buildCoinjoinRequirementSummary([
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 0, // no retry
+        mixdepth: 0,
+      } as Utxo,
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 0, // no retry
+        mixdepth: 0,
+      } as Utxo,
+      {
+        frozen: true, // not eligible
+        confirmations: 0,
+        tries_remaining: 0,
+        mixdepth: 0,
+      } as Utxo,
+    ])
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: 0,
+      numberOfMissingConfirmations: 0,
+      options: defaultOptions,
+      violations: [
+        {
+          hasViolations: true,
+          jarIndex: 0,
+          utxosViolatingMinConfirmations: [],
+          utxosViolatingRetriesLeft: [
+            {
+              confirmations: 5,
+              frozen: false,
+              tries_remaining: 0,
+              mixdepth: 0,
+            },
+            {
+              confirmations: 5,
+              frozen: false,
+              tries_remaining: 0,
+              mixdepth: 0,
+            },
+          ],
+        },
+      ],
+    })
+  })
+  it('should be fulfilled if at least one utxo has retries left in a jar', () => {
+    let preconditionSummary = buildCoinjoinRequirementSummary([
+      // 3 utxos in mixdepth 0
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 0, // no retry
+        mixdepth: 0,
+      } as Utxo,
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 0, // no retry
+        mixdepth: 0,
+      } as Utxo,
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 1,
+        mixdepth: 0,
+      } as Utxo,
+      // 2 utxos in mixdepth 4
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 0, // no retry
+        mixdepth: 4,
+      } as Utxo,
+      {
+        frozen: false,
+        confirmations: defaultOptions.minConfirmations,
+        tries_remaining: 1,
+        mixdepth: 4,
+      } as Utxo,
+    ])
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: true,
+      numberOfMissingUtxos: 0,
+      numberOfMissingConfirmations: 0,
+      options: defaultOptions,
+      violations: [],
+    })
+  })
+
+  it('should return used options in result summary', () => {
+    const customConfig = {
+      minNumberOfUtxos: 42,
+      minConfirmations: 1337,
+    }
+    const preconditionSummary = buildCoinjoinRequirementSummary([], customConfig)
+
+    expect(preconditionSummary).toEqual({
+      isFulfilled: false,
+      numberOfMissingUtxos: customConfig.minNumberOfUtxos,
+      numberOfMissingConfirmations: 0,
+      options: customConfig,
+      violations: [],
+    })
+  })
+})
diff --git a/src/utils.test.ts b/src/utils.test.ts
new file mode 100644
index 0000000..d7276d9
--- /dev/null
+++ b/src/utils.test.ts
@@ -0,0 +1,19 @@
+import { shortenStringMiddle } from './utils'
+
+describe('shortenStringMiddle', () => {
+  it('should shorten string in the middle', () => {
+    expect(shortenStringMiddle('0')).toBe('0')
+    expect(shortenStringMiddle('01')).toBe('01')
+    expect(shortenStringMiddle('01', -1)).toBe('01')
+    expect(shortenStringMiddle('01', 0)).toBe('01')
+    expect(shortenStringMiddle('01', 1)).toBe('01')
+    expect(shortenStringMiddle('01', 2)).toBe('01')
+    expect(shortenStringMiddle('0123456789abcdef', 2)).toBe('0…f')
+    expect(shortenStringMiddle('0123456789abcdef', 8)).toBe('0123…cdef')
+    expect(shortenStringMiddle('0123456789abcdef', 8, '...')).toBe('0123...cdef')
+    expect(shortenStringMiddle('0123456789abcdef', 14)).toBe('0123456…9abcdef')
+    expect(shortenStringMiddle('0123456789abcdef', 15)).toBe('0123456…9abcdef')
+    expect(shortenStringMiddle('0123456789abcdef', 16)).toBe('0123456789abcdef')
+    expect(shortenStringMiddle('0123456789abcdef', 32)).toBe('0123456789abcdef')
+  })
+})