diff --git a/eslint.config.js b/eslint.config.js index 9c38e2d5..23117c77 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,7 +45,6 @@ export default defineConfig( ignores: [ './src/components/ui/*.tsx', // shadcn components './src/**/*.stories.tsx', - './src/components/settings/CollaboratorFeesForm.tsx', // TODO: remove ], files: ['./src/**/*.{ts,tsx}'], languageOptions: { @@ -76,7 +75,6 @@ export default defineConfig( extends: [eslintPluginUnicorn.configs.recommended], ignores: [ './src/components/ui/*.tsx', // shadcn components - './src/components/settings/CollaboratorFeesForm.tsx', // TODO: remove ], rules: { 'unicorn/filename-case': ['off'], diff --git a/src/App.tsx b/src/App.tsx index 330de6dc..df450130 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,7 +48,7 @@ import { EarnReportPage } from './components/earn/report/EarnReportPage' import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDialog' import { Spinner } from './components/ui/spinner' import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage' -import { useJamSessionInfoContext, useRescanStatus } from './context/JamSessionInfoContext' +import { useJamSessionInfoContext } from './context/JamSessionInfoContext' import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider' import { useJamWalletInfoContext } from './context/JamWalletInfoContext' import { JmWebsocketContextProvider } from './context/JmWebsocketContextProvider' @@ -378,6 +378,7 @@ const RELOAD_WALLET_INFO_DELAY: { AFTER_RESCAN: Milliseconds AFTER_UTXO_CHANGE: Milliseconds AFTER_BLOCK_HEIGHT_CHANGE: Milliseconds + AFTER_TAKER_STOPPED: Milliseconds } = { // After rescanning, it is necessary to give the JM backend some time to synchronize. // A couple of seconds should be enough, however, this depends on the user hardware @@ -392,6 +393,9 @@ const RELOAD_WALLET_INFO_DELAY: { // Small delay is sufficient after block height change AFTER_BLOCK_HEIGHT_CHANGE: 210, + + // Small delay is sufficient after block height change + AFTER_TAKER_STOPPED: 210, } /** @@ -404,10 +408,14 @@ const RELOAD_WALLET_INFO_DELAY: { * always trigger a reload on demand and inform the user as they see fit. */ const WalletInfoAutoReload = () => { - const { rescanInfo: currentRescanInfo } = useRescanStatus() + const { + blockHeight: currentBlockHeight, + takerInfo: { running: currentTakerRunning }, + rescanInfo: currentRescanInfo, + } = useJamSessionInfoContext() const previousRescanningRef = useRef(currentRescanInfo.rescanning) - const { blockHeight: currentBlockHeight } = useJamSessionInfoContext() const previousBlockHeightRef = useRef(currentBlockHeight) + const previousTakerRunningRef = useRef(currentTakerRunning) const { refetch: refetchWalletBalance, utxosHashHex } = useJamWalletInfoContext() @@ -478,6 +486,26 @@ const WalletInfoAutoReload = () => { [refetchWalletBalance, utxosHashHex], ) + useEffect( + function refetchWalletInfoAfterTakerStopped() { + const takerStopped = previousTakerRunningRef.current === true && currentTakerRunning === false + previousTakerRunningRef.current = currentTakerRunning + if (!takerStopped) { + return + } + + const delayBefore = RELOAD_WALLET_INFO_DELAY.AFTER_TAKER_STOPPED + console.debug('Trigger refetch looking for funds AFTER_TAKER_STOPPED with delay %d...', delayBefore) + + const abortCtrl = new AbortController() + refetchWalletBalance({ delayBefore, signal: abortCtrl.signal }).catch((error: unknown) => { + console.error('Error while auto-reloading wallet info AFTER_TAKER_STOPPED finished', error) + }) + return () => abortCtrl.abort('useEffect(AFTER_TAKER_STOPPED) ended') + }, + [refetchWalletBalance, currentTakerRunning], + ) + return <> } diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx index 39227ca7..c1192a74 100644 --- a/src/components/earn/EarnPage.tsx +++ b/src/components/earn/EarnPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import type { ErrorMessage, StartMakerRequest } from '@joinmarket-webui/joinmarket-api-ts/jm' import { useMutation, useQuery } from '@tanstack/react-query' -import { AlertTriangleIcon, FileTextIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react' +import { AlertTriangleIcon, FileTextIcon, HourglassIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react' import type { SubmitHandler } from 'react-hook-form' import { Trans, useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' @@ -214,7 +214,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { toast.success(t('earn.alert_stopped'), { id: 'earn.alert_stopped' }) }, [jmSession, makerRunning, stopMaker.isSuccess, t]) - if (!jmSession) { + if (!jmSession || walletInfo.isLoading) { return } @@ -233,6 +233,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { setShowFeeConfigDialog(true)} className="mb-4" /> )} + {jmSession.coinjoin_in_process === true && ( + + + {t('send.text_coinjoin_already_running')} + + )} + {jmSession.maker_running === true && ( diff --git a/src/components/send/PaymentAbortDialog.tsx b/src/components/send/PaymentAbortDialog.tsx new file mode 100644 index 00000000..e311b689 --- /dev/null +++ b/src/components/send/PaymentAbortDialog.tsx @@ -0,0 +1,50 @@ +import type { ComponentProps } from 'react' +import { useTranslation } from 'react-i18next' +import type { WithRequiredProperty } from '@/types/global' +import { Button } from '../ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog' +import { Spinner } from '../ui/spinner' + +type PaymentAbortDialogProps = WithRequiredProperty< + Omit, 'children'>, + 'open' | 'onOpenChange' +> & { + isConfirming: boolean + onConfirm: () => Promise +} + +export const PaymentAbortDialog = ({ + open, + onOpenChange, + isConfirming, + onConfirm, + ...dialogProps +}: PaymentAbortDialogProps) => { + const { t } = useTranslation() + + return ( + + + + {t('send.confirm_abort_modal.title')} + {t('send.confirm_abort_modal.text_body')} + + + + + + + + ) +} diff --git a/src/components/send/PaymentConfirmDialog.tsx b/src/components/send/PaymentConfirmDialog.tsx index 8a4d5dd0..e70d56e1 100644 --- a/src/components/send/PaymentConfirmDialog.tsx +++ b/src/components/send/PaymentConfirmDialog.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ComponentProps, type ReactNode } from 'react' +import { useMemo, useState, type ComponentProps } from 'react' import { DialogTitle } from '@radix-ui/react-dialog' import { InfoIcon } from 'lucide-react' import { Trans, useTranslation } from 'react-i18next' @@ -33,8 +33,6 @@ type PaymentConfirmDialogProps = WithRequiredProperty< Omit, 'children'>, 'open' | 'onOpenChange' > & { - title: string - subtitle?: ReactNode | string onConfirm: (values: SendFormValues) => Promise values: SendFormValues meta: { @@ -49,8 +47,6 @@ type PaymentConfirmDialogProps = WithRequiredProperty< export default function PaymentConfirmDialog({ open, onOpenChange, - title, - subtitle, onConfirm, values, meta, @@ -94,8 +90,20 @@ export default function PaymentConfirmDialog({ - {title} - {subtitle} + + {t('send.confirm_send_modal.title')} + + + {values.isCoinJoin === true ? ( + + {t('send.confirm_send_modal.text_collaborative_tx_enabled')} + + ) : ( + + {t('send.confirm_send_modal.text_collaborative_tx_disabled')} + + )} +
diff --git a/src/components/send/SendForm.tsx b/src/components/send/SendForm.tsx index 830f1e19..7be3519a 100644 --- a/src/components/send/SendForm.tsx +++ b/src/components/send/SendForm.tsx @@ -114,7 +114,6 @@ interface SendFormProps { sourceJarLabelButton?: React.ReactElement minNumberOfCollaborators?: number feeConfigValues: JamFeeConfigValues - forceCoinJoinEnabled?: boolean walletFileName: WalletFileName jars: Jar[] walletBalanceSummary: BalanceSummary @@ -130,7 +129,6 @@ export function SendForm({ sourceJarLabelButton, disabled, feeConfigValues, - forceCoinJoinEnabled = false, walletFileName, minNumberOfCollaborators = MIN_NUM_COLLABORATORS, jars, @@ -186,7 +184,6 @@ export function SendForm({ const isSweep = useWatch({ control, name: 'amount.isSweep' }) const isCoinJoin = useWatch({ control, name: 'isCoinJoin' }) const collaboratorCount = useWatch({ control, name: 'numCollaborators' }) - const isCoinJoinEnabled = forceCoinJoinEnabled || isCoinJoin === true const destinationAddressInfo = useMemo(() => { try { @@ -209,19 +206,23 @@ export function SendForm({ if (destinationJarIndex === undefined) return return jars.find((it) => it.jarIndex === destinationJarIndex) }, [jars, destinationJarIndex]) + const coinjoinPreconditionSummary = useMemo(() => { if (!sourceJar) return undefined return buildSweepPreconditionSummary(sourceJar.utxos) }, [sourceJar]) - const hasCoinjoinPreconditionWarning = isCoinJoinEnabled && coinjoinPreconditionSummary?.isFulfilled === false + + const hasCoinjoinPreconditionWarning = isCoinJoin && coinjoinPreconditionSummary?.isFulfilled === false + const amountForFeeEstimate = useMemo(() => { if (values.amount?.isSweep === true) { return sourceJar?.balanceSummary.calculatedAvailableBalanceInSats } return values.amount?.amount }, [sourceJar, values.amount?.amount, values.amount?.isSweep]) + const estimatedMaxCollaboratorFee = useMemo(() => { - if (!isCoinJoinEnabled || values.numCollaborators === undefined || amountForFeeEstimate === undefined) { + if (!isCoinJoin || values.numCollaborators === undefined || amountForFeeEstimate === undefined) { return undefined } @@ -230,7 +231,7 @@ export function SendForm({ } catch (_ignoredOnPurpose) { return undefined } - }, [amountForFeeEstimate, feeConfigValues, isCoinJoinEnabled, values.numCollaborators]) + }, [amountForFeeEstimate, feeConfigValues, isCoinJoin, values.numCollaborators]) const doOnSubmit = handleSubmit(onSubmit) @@ -558,11 +559,8 @@ export function SendForm({
{ - if (forceCoinJoinEnabled) { - return - } setValue('isCoinJoin', checked, { shouldValidate: true, shouldDirty: true, @@ -588,7 +586,7 @@ export function SendForm({
- {isCoinJoinEnabled && ( + {isCoinJoin && (
@@ -636,7 +634,7 @@ export function SendForm({ variant={ disabled ? 'outline' - : !isCoinJoinEnabled + : !isCoinJoin ? 'destructive' : hasCoinjoinPreconditionWarning ? 'secondary' @@ -653,7 +651,7 @@ export function SendForm({ ) : ( <> - {!isCoinJoinEnabled ? ( + {!isCoinJoin ? ( <>{t('send.button_send_without_improved_privacy')} ) : hasCoinjoinPreconditionWarning ? ( <>{t('send.button_send_despite_warning')} diff --git a/src/components/send/SendPage.tsx b/src/components/send/SendPage.tsx index fd80e822..1ee308bc 100644 --- a/src/components/send/SendPage.tsx +++ b/src/components/send/SendPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { directsendMutation, docoinjoinMutation, @@ -21,6 +21,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert' import { PageLoading } from '@/components/ui/jam/PageLoading' import PageTitle from '@/components/ui/jam/PageTitle' +import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext' import { useAddressSummary, useJamWalletInfoContext, @@ -35,15 +36,15 @@ import { useRefreshSession } from '@/hooks/useRefreshSession' import { useUtxoSelectionDialog } from '@/hooks/useUtxoSelectionDialog' import { getErrorReason } from '@/lib/errorReason' import { withMutationDelay } from '@/lib/queryClient' -import type { WalletFileName } from '@/lib/utils' +import { scrollToTop, type WalletFileName } from '@/lib/utils' import { useDeveloperMode } from '@/store/jamSettingsStore' import { jmSessionStore } from '@/store/jmSessionStore' import { jmTxStore, type JmTxInfo } from '@/store/jmTxStore' import type { JarIndex } from '@/types/global' import { Button } from '../ui/button' import { Card, CardContent } from '../ui/card' -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog' import { Spinner } from '../ui/spinner' +import { PaymentAbortDialog } from './PaymentAbortDialog' import PaymentConfirmDialog from './PaymentConfirmDialog' import { SendForm } from './SendForm' import { UtxoSelectionDialog } from './UtxoSelectionDialog' @@ -60,6 +61,10 @@ type DirectSendResult = { request: DirectSendRequest response: DirectSendResponse } +type CollaborativeSendResult = { + request: DoCoinjoinRequest + response: unknown +} interface SendPageProps { walletFileName: WalletFileName @@ -70,8 +75,21 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { const client = useApiClient() const [formId, setFormId] = useState(0) const { fetchIfMissing } = useJmConfig({ walletFileName }) - const { refetch: refetchWalletInfo, waitForUtxosToBeSpent, setWaitForUtxosToBeSpent } = useJamWalletInfoContext() + const { + isLoading: walletInfoIsLoading, + isFetching: walletInfoIsFetching, + utxosHashHex, + waitForUtxosToBeSpent, + setWaitForUtxosToBeSpent, + } = useJamWalletInfoContext() const jmSession = useStore(jmSessionStore, (state) => state.state) + const { + takerInfo: { running: takerRunning, currentPaymentAttempt }, + rescanInfo, + setCurrentPaymentAttempt, + clearCurrentPaymentAttempt, + } = useJamSessionInfoContext() + const { enabled: isDeveloperMode } = useDeveloperMode() const feeConfigValidation = useFeeConfigValidation({ walletFileName }) @@ -79,36 +97,14 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { const [showPaymentConfirmDialog, setShowPaymentConfirmDialog] = useState(false) const [showAbortCoinjoinDialog, setShowAbortCoinjoinDialog] = useState(false) const [sendFromValuesAwaitingConfirmation, setSendFromValuesAwaitingConfirmation] = useState() - const [paymentSuccessfulInfoAlert, setPaymentSuccessfulInfoAlert] = useState() + const [nonCollaborativePaymentSuccessInfoAlert, setNonCollaborativePaymentSuccessInfoAlert] = useState() const [minimumCollaborators, setMinimumCollaborators] = useState() const [collaborativeFlowError, setCollaborativeFlowError] = useState() const [sourceJarIndex, setSourceJarIndex] = useState() - // TODO: "Lifecycle" or state management should be handled outside of this component - const collaborativeLifecycleRef = useRef({ - awaitingCompletion: false, - wasRunning: false, - utxoSnapshotAtStart: '', - }) - const refetchWalletInfoRef = useRef(refetchWalletInfo) - const currentUtxoSnapshotRef = useRef('') const { addressSummary } = useAddressSummary() const { walletBalanceSummary } = useWalletBalanceSummary() const { jars } = useJars() - const currentUtxoSnapshot = useMemo(() => { - return jars - .flatMap((jar) => jar.utxos.map((utxo) => utxo.utxo)) - .toSorted() - .join('|') - }, [jars]) - - useEffect(() => { - currentUtxoSnapshotRef.current = currentUtxoSnapshot - }, [currentUtxoSnapshot]) - - useEffect(() => { - refetchWalletInfoRef.current = refetchWalletInfo - }, [refetchWalletInfo]) const sourceJar = useMemo(() => { if (sourceJarIndex === undefined) return @@ -148,7 +144,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { setCollaborativeFlowError(undefined) }, onSuccess: () => { - toast.success(t('send.alert_collaborative_started_title')) + toast.info(t('send.alert_collaborative_starting')) }, onError: (error: ErrorMessage) => { const reason = getErrorReason(error, t('global.errors.reason_unknown')) @@ -181,6 +177,10 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { onMutate: () => { setCollaborativeFlowError(undefined) }, + onSuccess: () => { + clearCurrentPaymentAttempt() + toast.info(t('send.alert_collaborative_stopping')) + }, onError: (error: unknown) => { const reason = getErrorReason(error, t('global.errors.reason_unknown')) const message = t('send.error_stopping_collaborative_transaction', { reason }) @@ -189,10 +189,8 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { }, }) - const coinjoinRunning = jmSession?.coinjoin_in_process === true - const isWaitingCoinjoinStart = startCoinjoinMutationIsPending || (startCoinjoinMutationIsSuccess && !coinjoinRunning) - const isWaitingCoinjoinStop = stopCoinjoinMutationIsPending || (stopCoinjoinMutationIsSuccess && coinjoinRunning) - const collaborativeFlowActive = coinjoinRunning || isWaitingCoinjoinStart || isWaitingCoinjoinStop + const isWaitingCoinjoinStart = startCoinjoinMutationIsPending || (startCoinjoinMutationIsSuccess && !takerRunning) + const isWaitingCoinjoinStop = stopCoinjoinMutationIsPending || (stopCoinjoinMutationIsSuccess && takerRunning) useRefreshSession({ enabled: isWaitingCoinjoinStart || isWaitingCoinjoinStop, @@ -200,117 +198,45 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { refetchDelay: 1_000, }) - useEffect(() => { - if (!collaborativeFlowActive) { - return - } - - void refetchWalletInfoRef.current() - const intervalId = window.setInterval(() => { - void refetchWalletInfoRef.current() - }, 3_000) - - return () => { - window.clearInterval(intervalId) - } - }, [collaborativeFlowActive]) + useRefreshSession({ + enabled: takerRunning, + refetchInterval: 5_000, + refetchDelay: 1_000, + }) useEffect(() => { - if (coinjoinRunning && startCoinjoinMutationIsSuccess) { + if (takerRunning && startCoinjoinMutationIsSuccess) { startCoinjoinMutationReset() } - }, [coinjoinRunning, startCoinjoinMutationIsSuccess, startCoinjoinMutationReset]) + }, [takerRunning, startCoinjoinMutationIsSuccess, startCoinjoinMutationReset]) useEffect(() => { - if (!coinjoinRunning && stopCoinjoinMutationIsSuccess) { + if (!takerRunning && stopCoinjoinMutationIsSuccess) { stopCoinjoinMutationReset() } - }, [coinjoinRunning, stopCoinjoinMutationIsSuccess, stopCoinjoinMutationReset]) + }, [takerRunning, stopCoinjoinMutationIsSuccess, stopCoinjoinMutationReset]) useEffect(() => { - const state = collaborativeLifecycleRef.current - - if (!state.awaitingCompletion) { - return - } - - if (coinjoinRunning) { - state.wasRunning = true - return - } - - if (!state.wasRunning) { - return - } - - state.awaitingCompletion = false - state.wasRunning = false - const utxoSnapshotAtStart = state.utxoSnapshotAtStart - let isCancelled = false - - const verifyCollaborativeCompletion = async () => { - const deadline = Date.now() + 9_000 - let hasNewTransaction = currentUtxoSnapshotRef.current !== utxoSnapshotAtStart - - while (!hasNewTransaction && Date.now() < deadline) { - await refetchWalletInfoRef.current() - if (isCancelled) return - - hasNewTransaction = currentUtxoSnapshotRef.current !== utxoSnapshotAtStart - if (hasNewTransaction || Date.now() >= deadline) break - - await new Promise((resolve) => window.setTimeout(resolve, 1_000)) - } - - if (isCancelled) return - - queueMicrotask(() => { - setPaymentSuccessfulInfoAlert({ - variant: hasNewTransaction ? 'success' : 'warning', - title: hasNewTransaction - ? t('send.alert_collaborative_completed_title') - : t('send.alert_collaborative_ended_title'), - description: hasNewTransaction - ? t('send.alert_collaborative_completed_description') - : t('send.alert_collaborative_ended_description'), - }) - if (hasNewTransaction) { - toast.success(t('send.alert_collaborative_completed_title')) - } else { - toast.warning(t('send.alert_collaborative_ended_title')) - } - }) - } - - void verifyCollaborativeCompletion() - - return () => { - isCancelled = true - } - }, [coinjoinRunning, t]) - - useEffect(() => { - let isCancelled = false + const abortCtrl = new AbortController() fetchIfMissing({ section: 'POLICY', field: 'minimum_makers' }) .then((result) => { - if (isCancelled) return + if (abortCtrl.signal.aborted) return const parsedValue = Number.parseInt(result.value || '', 10) - if (!Number.isInteger(parsedValue) || parsedValue < 1) { + if (!Number.isSafeInteger(parsedValue) || parsedValue < 1) { return } setMinimumCollaborators(parsedValue) }) .catch((error: unknown) => { - if (isCancelled) return + if (abortCtrl.signal.aborted) return const reason = getErrorReason(error, t('global.errors.reason_unknown')) + // TODO: i18n, add reason as param toast.error(`${t('send.error_loading_min_makers_failed')} ${reason}`) }) - return () => { - isCancelled = true - } + return () => abortCtrl.abort() }, [fetchIfMissing, t]) const triggerNonCollaborativeTransaction = useMutation({ @@ -332,7 +258,8 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { { throttle: 2_100 }, ), onMutate: () => { - setPaymentSuccessfulInfoAlert(undefined) + setNonCollaborativePaymentSuccessInfoAlert(undefined) + scrollToTop() }, onSuccess: (result: DirectSendResult, data: SendFormValues) => { const tx = result.response.txinfo as Required @@ -345,7 +272,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { setWaitForUtxosToBeSpent(inputUtxoIds) setFormId((current) => current + 1) - setPaymentSuccessfulInfoAlert({ + setNonCollaborativePaymentSuccessInfoAlert({ variant: 'success', title: /* TODO: i18n */ 'Successfully sent non-collaborative transaction', description: t('send.alert_payment_successful', { @@ -364,61 +291,76 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { }, }) - const onPaymentConfirmed: SubmitHandler = async (data: SendFormValues) => { - if (data.isCoinJoin !== true) { + const triggerCollaborativeTransaction = useMutation({ + mutationFn: withMutationDelay( + async (data: SendFormValues) => { + const body: DoCoinjoinRequest = buildCollaborativeSendRequest(data) + const response = await startCoinjoinMutationMutateAsync({ + path: { walletname: walletFileName }, + body, + }) + + return { + request: body, + response, + } + }, + { throttle: 0 }, + ), + onMutate: () => { + setNonCollaborativePaymentSuccessInfoAlert(undefined) + scrollToTop() + }, + onSuccess: (_result, data) => { + setCurrentPaymentAttempt({ + createdAt: Date.now(), + walletFileName, + utxosHashHex, + data, + }) + }, + }) + + const onPaymentValuesConfirmed = async (data: SendFormValues) => { + setShowPaymentConfirmDialog(false) + setSendFromValuesAwaitingConfirmation(undefined) + + if (data.isCoinJoin === true) { + if (feeConfigValidation.maxFeesConfigMissing) { + toast.error(t('send.taker_error_message_max_fees_config_missing')) + throw new Error(t('send.taker_error_message_max_fees_config_missing')) + } + + try { + await triggerCollaborativeTransaction.mutateAsync(data) + } catch (error: unknown) { + console.error('Error while sending collaborative transaction', error) + } + } else { try { await triggerNonCollaborativeTransaction.mutateAsync(data) } catch (error: unknown) { console.error('Error while sending non-collaborative transaction', error) } - } else { - if (feeConfigValidation.maxFeesConfigMissing) { - toast.error(t('send.taker_error_message_max_fees_config_missing')) - setShowFeeConfigDialog(true) - return - } - - try { - const body: DoCoinjoinRequest = buildCollaborativeSendRequest(data) - setPaymentSuccessfulInfoAlert(undefined) - collaborativeLifecycleRef.current.utxoSnapshotAtStart = currentUtxoSnapshot - await startCoinjoinMutationMutateAsync({ - path: { walletname: walletFileName }, - body, - }) - collaborativeLifecycleRef.current.awaitingCompletion = true - collaborativeLifecycleRef.current.wasRunning = coinjoinRunning - setPaymentSuccessfulInfoAlert({ - variant: 'success', - title: t('send.alert_collaborative_started_title'), - description: t('send.alert_collaborative_started_description'), - }) - } catch (error: unknown) { - collaborativeLifecycleRef.current.utxoSnapshotAtStart = '' - const reason = getErrorReason(error, t('global.errors.reason_unknown')) - const message = t('send.error_preparing_collaborative_transaction', { reason }) - setCollaborativeFlowError(message) - toast.error(message) - } } } const onSubmit: SubmitHandler = (data) => { + if (data.isCoinJoin && feeConfigValidation.maxFeesConfigMissing) { + toast.error(t('send.taker_error_message_max_fees_config_missing')) + setShowFeeConfigDialog(true) + return + } setSendFromValuesAwaitingConfirmation(data) setShowPaymentConfirmDialog(true) } - const onAbortCoinjoin = async () => { - collaborativeLifecycleRef.current.awaitingCompletion = false - collaborativeLifecycleRef.current.wasRunning = false - collaborativeLifecycleRef.current.utxoSnapshotAtStart = '' - setPaymentSuccessfulInfoAlert(undefined) + const onAbortCoinjoinConfirmed = async () => { setShowAbortCoinjoinDialog(false) await stopCoinjoinMutationMutateAsync() - void refetchWalletInfoRef.current() } - if (feeConfigValidation.isLoading) { + if (!jmSession || walletInfoIsLoading) { return } @@ -430,53 +372,21 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { open={showFeeConfigDialog} onOpenChange={setShowFeeConfigDialog} /> - - - - {t('send.confirm_abort_modal.title')} - {t('send.confirm_abort_modal.text_body')} - - - - - - - + + + {sourceJar && sendFromValuesAwaitingConfirmation && ( - {t('send.confirm_send_modal.text_collaborative_tx_enabled')} - - ) : ( - {t('send.confirm_send_modal.text_collaborative_tx_disabled')} - ) - } values={sendFromValuesAwaitingConfirmation} - onConfirm={async () => { - setShowPaymentConfirmDialog(false) - await onPaymentConfirmed(sendFromValuesAwaitingConfirmation) - }} + onConfirm={onPaymentValuesConfirmed} meta={{ feeConfigValues: feeConfigValidation.feeConfigValues, availableUtxos: availableUtxosForPayment, @@ -492,7 +402,12 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { {feeConfigValidation.maxFeesConfigMissing && ( setShowFeeConfigDialog(true)} className="mb-4" /> )} - + {jmSession?.maker_running === true && ( + + + {t('send.text_maker_running')} + + )} {collaborativeFlowError && ( @@ -500,34 +415,122 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { {collaborativeFlowError} )} - {isWaitingCoinjoinStart && ( {t('send.alert_collaborative_starting')} )} + {isWaitingCoinjoinStop && ( + + + {t('send.alert_collaborative_stopping')} + + )} - {coinjoinRunning && ( + { + /* + * With backend "joinmarket-clientserver" there is no direct way of verifying + * that a collaborative send attempt was successful. + * We have to rely on local data `takerCurrentAttempt` + * (which might not be present, e.g. taker was started from a different session) + * for displaying success/error information. + * If data `takerCurrentAttempt` is not present, no message is shown + * when the taker service stops - this is not ideal, but okay. + */ + currentPaymentAttempt !== undefined && + currentPaymentAttempt.data.isCoinJoin && + !isWaitingCoinjoinStart && + takerRunning === false && ( + <> + {walletInfoIsFetching ? ( + <> + + + {t('send.alert_collaborative_awaiting_completion')} + + + ) : ( + <> + {currentPaymentAttempt.utxosHashHex === utxosHashHex ? ( + + + {t('send.alert_collaborative_ended_title')} + +
{t('send.alert_collaborative_ended_description')}
+
+ +
+
+
+ ) : ( + + + {t('send.alert_collaborative_completed_title')} + +
{t('send.alert_collaborative_completed_description')}
+
+ +
+
+
+ )} + + )} + + ) + } + {takerRunning && !isWaitingCoinjoinStop && ( {t('send.text_coinjoin_already_running')} - - + + {currentPaymentAttempt && ( +
+                  {JSON.stringify(
+                    {
+                      sourceJar: currentPaymentAttempt.data.source.fromJar,
+                      destinationJar: currentPaymentAttempt.data.destination.fromJar,
+                      destinationAddress: currentPaymentAttempt.data.destination.address,
+                      isSweep: currentPaymentAttempt.data.amount.isSweep === true,
+                      amount:
+                        currentPaymentAttempt.data.amount.isSweep === true
+                          ? currentPaymentAttempt.data.amount.sweepAmount
+                          : currentPaymentAttempt.data.amount.amount,
+                      numCollaborators: currentPaymentAttempt.data.numCollaborators,
+                    },
+                    null,
+                    2,
+                  )}
+                
+ )} +
+ +
)} @@ -536,7 +539,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { {/* TODO: i18n */}Error while sending non-collaborative transaction - +

The exact reason is not entirely clear, only the following is known:{' '} @@ -560,19 +563,18 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { {/* TODO: i18n*/ t('Waiting for utxos to be marked as spent...')} )} - {paymentSuccessfulInfoAlert && !coinjoinRunning && ( - + {nonCollaborativePaymentSuccessInfoAlert && ( + - {paymentSuccessfulInfoAlert.title} - - {paymentSuccessfulInfoAlert.description} + {nonCollaborativePaymentSuccessInfoAlert.title} + + {nonCollaborativePaymentSuccessInfoAlert.description} )} )} - {/* Earn Form */} { walletFileName={walletFileName} minNumberOfCollaborators={minimumCollaborators} feeConfigValues={feeConfigValidation.feeConfigValues} - forceCoinJoinEnabled={collaborativeFlowActive} jars={jars} addressSummary={addressSummary} walletBalanceSummary={walletBalanceSummary} disabled={ - feeConfigValidation.maxFeesConfigMissing || jmSession?.maker_running === true || - collaborativeFlowActive || - jmSession?.rescanning === true || + takerRunning || + rescanInfo.rescanning || + isWaitingCoinjoinStart || + isWaitingCoinjoinStop || utxoSelectionDialog.isSubmitting || triggerNonCollaborativeTransaction.isPending || + triggerCollaborativeTransaction.isPending || + currentPaymentAttempt !== undefined || waitForUtxosToBeSpent.length > 0 } debug={isDeveloperMode} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 21836e51..68fdd08f 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -6,7 +6,6 @@ import { EyeOffIcon, SunIcon, MoonIcon, - DollarSignIcon, FileTextIcon, BookIcon, TerminalIcon, @@ -16,6 +15,7 @@ import { FoldHorizontalIcon, UnfoldHorizontalIcon, KeyRoundIcon, + HandCoinsIcon, } from 'lucide-react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' @@ -126,7 +126,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) setShowFeeConfigDialog(true)} /> diff --git a/src/components/sweep/SweepPage.tsx b/src/components/sweep/SweepPage.tsx index cce9943f..dc82e5e5 100644 --- a/src/components/sweep/SweepPage.tsx +++ b/src/components/sweep/SweepPage.tsx @@ -315,12 +315,12 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { await stopScheduleMutationMutateAsync() } - if (feeConfigValidation.isLoading || walletInfo.isLoading || jmSession === undefined) { + if (!jmSession || feeConfigValidation.isLoading || walletInfo.isLoading) { return } return ( -

+ <> { disabled={isStartDisabled || isWaitingSchedulerStart} isStarting={isWaitingSchedulerStart} /> +
+ - + {feeConfigValidation.maxFeesConfigMissing && ( + setShowFeeConfigDialog(true)} className="mb-4" /> + )} - {feeConfigValidation.maxFeesConfigMissing && ( - setShowFeeConfigDialog(true)} className="mb-4" /> - )} + {alertMessage && ( + + {t('global.error')} + {alertMessage} + + )} - {alertMessage && ( - - {t('global.error')} - {alertMessage} - - )} + {singleCoinJoinRunning && ( + + + {t('send.text_coinjoin_already_running')} + + )} - {singleCoinJoinRunning && ( - - - {t('send.text_coinjoin_already_running')} - - )} + {makerRunning && ( + + + {t('send.text_maker_running')} + + )} - {makerRunning && !schedulerRunning && ( - - - {t('send.text_maker_running')} - - )} + {isWaitingSchedulerStart && ( + + + {t('scheduler.button_start')} + + )} - {isWaitingSchedulerStart && ( - - - {t('scheduler.button_start')} - - )} + {isWaitingSchedulerStop && ( + + + {t('scheduler.button_stop')} + + )} - {isWaitingSchedulerStop && ( - - - {t('scheduler.button_stop')} - - )} + {schedulerRunning && currentSchedule && ( + + )} - {schedulerRunning && currentSchedule && ( - - )} + {!schedulerRunning && ( + <> + - {!schedulerRunning && ( - <> - - - - -
-
-
{t('scheduler.complete_wallet_title')}
-
{t('scheduler.complete_wallet_subtitle')}
+ + +
+
+
{t('scheduler.complete_wallet_title')}
+
{t('scheduler.complete_wallet_subtitle')}
+
+
+ +
-
- -
-
-

{t('scheduler.description_destination_addresses')}

+

{t('scheduler.description_destination_addresses')}

- {showInsecureScheduleTestingToggle && ( -
- - -
- )} - - - -

{t('scheduler.description_fees')}

- - - - - - )} -
+ + + +

{t('scheduler.description_fees')}

+ + +
+
+ + )} +
+ ) } diff --git a/src/components/ui/jam/FeeConfigErrorAlert.tsx b/src/components/ui/jam/FeeConfigErrorAlert.tsx index aed5f764..7bf16508 100644 --- a/src/components/ui/jam/FeeConfigErrorAlert.tsx +++ b/src/components/ui/jam/FeeConfigErrorAlert.tsx @@ -1,4 +1,4 @@ -import { SettingsIcon } from 'lucide-react' +import { AlertTriangleIcon, HandCoinsIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -13,12 +13,15 @@ export const FeeConfigErrorAlert = ({ onOpenFeeConfig, className }: FeeConfigErr return ( - - {t('send.taker_error_message_max_fees_config_missing')} - + + +
{t('send.taker_error_message_max_fees_config_missing')}
+
+ +
) diff --git a/src/context/JamSessionInfoContext.ts b/src/context/JamSessionInfoContext.ts index e4bcc4d2..70f7fae6 100644 --- a/src/context/JamSessionInfoContext.ts +++ b/src/context/JamSessionInfoContext.ts @@ -1,4 +1,6 @@ import { createContext, useContext, type Dispatch, type SetStateAction } from 'react' +import type { SendFormValues } from '@/components/send/types' +import type { WalletFileName } from '@/lib/utils' export interface RescanInfo { updatedAt: number @@ -6,10 +8,25 @@ export interface RescanInfo { progress?: number } +export interface PaymentAttempt { + createdAt: number + utxosHashHex: string + walletFileName: WalletFileName + data: SendFormValues +} + +export interface TakerInfo { + currentPaymentAttempt?: PaymentAttempt + running: boolean +} + interface JamSessionInfoContextType { blockHeight?: number + takerInfo: TakerInfo rescanInfo: RescanInfo setRescanInfo: Dispatch> + setCurrentPaymentAttempt: (val: PaymentAttempt) => void + clearCurrentPaymentAttempt: () => void } export const JamSessionInfoContext = createContext(undefined) diff --git a/src/context/JamSessionInfoContextProvider.tsx b/src/context/JamSessionInfoContextProvider.tsx index 4295ed79..0f75861a 100644 --- a/src/context/JamSessionInfoContextProvider.tsx +++ b/src/context/JamSessionInfoContextProvider.tsx @@ -2,14 +2,35 @@ import type { PropsWithChildren } from 'react' import { useMemo, useState } from 'react' import { getrescaninfoOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query' import { useQuery } from '@tanstack/react-query' -import { useStore } from 'zustand' +import { createStore, useStore } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' import { JAM_RESCAN_PROGRESS_INTERVAL } from '@/constants/jam' import { useApiClient } from '@/hooks/useApiClient' import { withQueryDelay } from '@/lib/queryClient' import type { WalletFileName } from '@/lib/utils' import { jmSessionStore } from '@/store/jmSessionStore' import { JamSessionInfoContext } from './JamSessionInfoContext' -import type { RescanInfo } from './JamSessionInfoContext' +import type { PaymentAttempt, RescanInfo, TakerInfo } from './JamSessionInfoContext' + +interface PaymentAttemptStoreState { + state?: PaymentAttempt + update: (val: PaymentAttempt) => void + clear: () => void +} + +const paymentAttemptStore = createStore()( + persist( + (set) => ({ + state: undefined, + update: (val) => set(() => ({ state: val })), + clear: () => set({ state: undefined }), + }), + { + name: 'jam-payment-attempt-store', + storage: createJSONStorage(() => sessionStorage), + }, + ), +) interface JamSessionInfoContextProviderProps { walletFileName: WalletFileName @@ -26,6 +47,22 @@ export const JamSessionInfoContextProvider = ({ rescanning: state?.rescanning === true, }) + const { + state: currentPaymentAttempt, + update: setCurrentPaymentAttempt, + clear: clearCurrentPaymentAttempt, + } = useStore(paymentAttemptStore, (state) => state) + + const takerInfo = useMemo(() => { + const isWalletPayment = currentPaymentAttempt?.walletFileName === walletFileName + const isCoinJoin = currentPaymentAttempt?.data.isCoinJoin === true + const takerIsRunning = state?.coinjoin_in_process === true + return { + currentPaymentAttempt: isWalletPayment && isCoinJoin ? currentPaymentAttempt : undefined, + running: takerIsRunning, + } + }, [state?.coinjoin_in_process, walletFileName, currentPaymentAttempt]) + const getrescaninfoQueryOptions = useMemo( () => getrescaninfoOptions({ @@ -64,8 +101,12 @@ export const JamSessionInfoContextProvider = ({ const value = { blockHeight: state?.block_height, + takerRunning: state?.coinjoin_in_process === true, + takerInfo, rescanInfo, setRescanInfo, + setCurrentPaymentAttempt, + clearCurrentPaymentAttempt, } return {children} diff --git a/src/hooks/useRefreshSession.ts b/src/hooks/useRefreshSession.ts index 739f143a..0e53e325 100644 --- a/src/hooks/useRefreshSession.ts +++ b/src/hooks/useRefreshSession.ts @@ -53,7 +53,9 @@ export function useRefreshSession({ useEffect( function refetchOnWalletLockOrUnlock() { + const abortCtrl = new AbortController() refetchSessionData().catch(() => { + if (abortCtrl.signal.aborted) return const isDevMode = jamSettingsStore.getState().state.developerMode if (isDevMode) { toast.error(`[DEV] Error while refreshing session data.`, { @@ -61,6 +63,7 @@ export function useRefreshSession({ }) } }) + return () => abortCtrl.abort() }, [authState?.walletFileName, refetchSessionData], ) diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 4173414a..e9caa987 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -371,15 +371,14 @@ "error_loading_min_makers_failed": "Loading config value 'minimum_makers' failed.", "alert_payment_successful": "Payment successful: Sent {{ amount }} sats to {{ address }} in transaction {{ txid }}.", "alert_collaborative_starting": "Starting collaborative transaction...", - "alert_collaborative_started_title": "Collaborative transaction started", - "alert_collaborative_started_description": "JoinMarket accepted your request and is now coordinating the transaction.", + "alert_collaborative_stopping": "Aborting collaborative transaction...", + "alert_collaborative_awaiting_completion": "Awaiting completion of collaborative transaction...", "alert_collaborative_completed_title": "Collaborative transaction completed", "alert_collaborative_completed_description": "JoinMarket finished the collaborative transaction successfully.", "alert_collaborative_ended_title": "Collaborative flow ended", "alert_collaborative_ended_description": "The collaborative flow stopped, but no new completed transaction was confirmed yet. Please verify the recipient and transaction history.", "error_starting_collaborative_transaction": "Error while starting collaborative transaction: {{ reason }}", "error_stopping_collaborative_transaction": "Error while stopping collaborative transaction: {{ reason }}", - "error_preparing_collaborative_transaction": "Error while preparing collaborative transaction: {{ reason }}", "text_maker_running": "Earn is active. Stop the service in order to send collaborative transactions.", "text_coinjoin_already_running": "A collaborative transaction is currently in progress.", "label_recipient": "Recipient", diff --git a/src/stories/jam/dialogs/PaymentAbortDialog.stories.tsx b/src/stories/jam/dialogs/PaymentAbortDialog.stories.tsx new file mode 100644 index 00000000..c6b3241a --- /dev/null +++ b/src/stories/jam/dialogs/PaymentAbortDialog.stories.tsx @@ -0,0 +1,36 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { PaymentAbortDialog } from '@/components/send/PaymentAbortDialog' +import { Button } from '@/components/ui/button' + +const meta: Meta = { + title: 'Dialog/PaymentAbortDialog', + component: PaymentAbortDialog, + tags: ['autodocs'], + render: (args) => { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} /> + + ) + }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + isConfirming: false, + onConfirm: async () => alert('Confirm clicked!'), + }, +} + +export const Confirming: Story = { + args: { + isConfirming: true, + onConfirm: async () => alert('Confirm clicked!'), + }, +}