diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml index 9e26df94f..f56c1114f 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/components/CoinjoinPreconditionViolationAlert.tsx b/src/components/CoinjoinPreconditionViolationAlert.tsx new file mode 100644 index 000000000..d2ea2b24e --- /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) => { + const { t } = useTranslation() + const settings = useSettings() + + if (summary.isFulfilled) return <> + + if (summary.numberOfMissingUtxos > 0) { + return ( + + {t(`${i18nPrefix}hint_missing_utxos`, { + minConfirmations: summary.options.minConfirmations, + })} + + ) + } + + if (summary.numberOfMissingConfirmations > 0) { + return ( + + {t(`${i18nPrefix}hint_missing_confirmations`, { + minConfirmations: summary.options.minConfirmations, + amountOfMissingConfirmations: summary.numberOfMissingConfirmations, + })} + + ) + } + + const utxosViolatingRetriesLeft = summary.violations + .map((it) => it.utxosViolatingRetriesLeft) + .reduce((acc, utxos) => acc.concat(utxos), []) + + if (utxosViolatingRetriesLeft.length > 0) { + return ( + + <> + + You tried too many times. See + + the docs + {' '} + for more info. + +
+
+ + Following utxos have been used unsuccessfully too many times: +
    + {utxosViolatingRetriesLeft.map((utxo, index) => ( +
  • + + + + {jarInitial(utxo.mixdepth)} + + : + +
    + {utxo.address} +  ( + + ) +
    + {shortenStringMiddle(utxo.utxo, 32)} +
    +
  • + ))} +
+
+ +
+ ) + } + + return <> + } +) diff --git a/src/components/Jam.jsx b/src/components/Jam.jsx index de7f26a87..af344e85e 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() { )} - - <> - {schedulerPreconditionSummary.numberOfMissingUtxos > 0 ? ( - - To run the scheduler you need at least one UTXO with{' '} - {{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}{' '} - confirmations. Fund your wallet and wait for{' '} - {{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}{' '} - blocks. - - ) : schedulerPreconditionSummary.amountOfMissingConfirmations > 0 ? ( - - The scheduler requires one of your UTXOs to have{' '} - - {{ - /* 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, - }} - {' '} - or more confirmations. Wait for{' '} - - {{ amountOfMissingConfirmations: schedulerPreconditionSummary.amountOfMissingConfirmations }} - {' '} - more block(s). - - ) : ( - schedulerPreconditionSummary.amountOfMissingOverallRetries > 0 && ( - - 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{' '} - - the docs - {' '} - for more information. - - ) - )} - - + {!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)} >
{collaborativeOperationRunning ? t('scheduler.button_stop') : t('scheduler.button_start')} diff --git a/src/components/Send.jsx b/src/components/Send.jsx index af68194bb..fc26e6ace 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 ( - - <> - {coinjoinPreconditionSummary.numberOfMissingUtxos > 0 ? ( - - To execute a collaborative transaction you need at least one UTXO with{' '} - {{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }}{' '} - confirmations in the source jar. Select another jar to send from or fund this jar and wait for{' '} - {{ minConfirmations: COINJOIN_PRECONDITIONS.MIN_CONFIRMATIONS_OF_SINGLE_UTXO }} blocks. - - ) : coinjoinPreconditionSummary.amountOfMissingConfirmations > 0 ? ( - - A collaborative transaction requires one of your UTXOs to have{' '} - - {{ - /* 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, - }} - {' '} - or more confirmations. Select another jar to send from or wait for{' '} - - {{ amountOfMissingConfirmations: coinjoinPreconditionSummary.amountOfMissingConfirmations }} - {' '} - more block(s). - - ) : ( - coinjoinPreconditionSummary.amountOfMissingOverallRetries > 0 && ( - - 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{' '} - - the docs - {' '} - for more information. - - ) - )} - - - ) -} - 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 && ( - +
+ +
)} {!isLoading && walletInfo && ( diff --git a/src/hooks/CoinjoinPrecondition.test.tsx b/src/hooks/CoinjoinPrecondition.test.tsx deleted file mode 100644 index 9b5921813..000000000 --- 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() - - 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/CoinjoinPrecondition.ts b/src/hooks/CoinjoinPrecondition.ts deleted file mode 100644 index 4c2d47d13..000000000 --- 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.test.ts b/src/hooks/CoinjoinRequirements.test.ts new file mode 100644 index 000000000..b725616e9 --- /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/hooks/CoinjoinRequirements.ts b/src/hooks/CoinjoinRequirements.ts new file mode 100644 index 000000000..0edb3f2f1 --- /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 d29b83458..d8aa496b7 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 }} confirmations in the source jar. Select another jar to send from or fund this jar and wait for <6>{{ minConfirmations }} blocks.", - "hint_missing_confirmations": "A collaborative transaction requires one of your UTXOs to have <2>{{ minConfirmations }} or more confirmations. Select another jar to send from or wait for <6>{{ amountOfMissingConfirmations }} 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 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 for more information.", + "hint_missing_retries_detail_one": "The following UTXO has been used unsuccessfully too many times and cannot be reused: <1>", + "hint_missing_retries_detail_other": "The following {{ count }} UTXOs have been used unsuccessfully too many times and cannot be reused: <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": { @@ -454,9 +460,15 @@ "progress_current_state": "Waiting for transaction <1>{{ current }} of <3>{{ total }} 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 }} confirmations in Jar A. Fund your wallet and wait for <6>{{ minConfirmations }} blocks.", - "hint_missing_confirmations": "The scheduler requires one of your UTXOs in Jar A to have <2>{{ minConfirmations }} or more confirmations. Wait for <6>{{ amountOfMissingConfirmations }} 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 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 for more information.", + "hint_missing_retries_detail_one": "The following UTXO has been used unsuccessfully too many times and cannot be reused: <1>", + "hint_missing_retries_detail_other": "The following {{ count }} UTXOs have been used unsuccessfully too many times and cannot be reused: <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.test.ts b/src/utils.test.ts new file mode 100644 index 000000000..d7276d90a --- /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') + }) +}) diff --git a/src/utils.ts b/src/utils.ts index 99a6aa50c..d353e530b 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)}` +}