diff --git a/.env b/.env index 07d7fd7d..e967dd87 100644 --- a/.env +++ b/.env @@ -6,9 +6,15 @@ VITE_JAM_JM_SESSION_REFRESH_INTERVAL=30000 VITE_JAM_RESCAN_PROGRESS_INTERVAL=10000 VITE_JAM_SEED_MODAL_TIMEOUT=30000 +VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=3 +VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3 + VITE_JM_WEBSOCKET_ENDPOINT_PATH=/jmws VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION=1000 VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION=3000 VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL=30000 VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=5000 VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000 + +VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10 +VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90 diff --git a/.env.development b/.env.development index ea09cf5e..3a37e0c6 100644 --- a/.env.development +++ b/.env.development @@ -5,3 +5,11 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=3000 VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=30000 VITE_JAM_SEED_MODAL_TIMEOUT=21000 + +VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=59 +VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=59 + +VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1 +VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3 + +VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1 diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..f379760f --- /dev/null +++ b/.env.test @@ -0,0 +1,5 @@ +VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1 +VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3 + +VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1 +VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 862ef55f..01fa7889 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,7 @@ jobs: run: npm run format:check - name: Test - run: npm test -- --coverage.enabled + run: npm run test:coverage - name: Build run: npm run build diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ad3798d6..d4363bf0 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,7 +6,7 @@ import { initialize, mswLoader } from 'msw-storybook-addon' import { ThemeProvider } from 'next-themes' import { I18nextProvider } from 'react-i18next' import { MemoryRouter } from 'react-router-dom' -import { CoreTypes, GlobalTypes } from 'storybook/internal/csf' +import type { CoreTypes, GlobalTypes } from 'storybook/internal/csf' import { DEFAULT_VIEWPORT, MINIMAL_VIEWPORTS, INITIAL_VIEWPORTS } from 'storybook/viewport' import { JamDisplayContextProvider } from '../src/context/JamDisplayContextProvider' import { JamWalletInfoContextProvider } from '../src/context/JamWalletInfoContextProvider' diff --git a/docker/regtest/init-setup.sh b/docker/regtest/init-setup.sh index a5093503..a5cd53e1 100755 --- a/docker/regtest/init-setup.sh +++ b/docker/regtest/init-setup.sh @@ -35,10 +35,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P) dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0) dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7) dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21) +dummy_wallet_address_fb0='bcrt1q7war3lusq3ez633rzzfkp75unfcgrqqcu80sgp0ellhrdvkzxh5swspygk' # 1st fb address (m/84'/1'/0'/2/0) +dummy_wallet_address_fb959='bcrt1q6rlcknptmqhf4w20928tpz6px4y70960gyupd029c7vx7kdjwt0q46nk9c' # 960th fb address (m/84'/1'/0'/2/959) # make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106 . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null . "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null +. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb0" &>/dev/null +. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb959" &>/dev/null . "$script_dir/mine-block.sh" 100 &>/dev/null start_maker() { diff --git a/package-lock.json b/package-lock.json index dcce415e..af7b65d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-radio-group": "1.4.3", "@radix-ui/react-select": "2.3.3", "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-slider": "1.4.3", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-switch": "1.3.3", "@radix-ui/react-tabs": "1.1.17", diff --git a/package.json b/package.json index a6e06df8..52eb1eaa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test": "NODE_OPTIONS=--no-webstorage vitest run", "test:unit": "NODE_OPTIONS=--no-webstorage vitest run --project unit", "test:storybook": "NODE_OPTIONS=--no-webstorage vitest run --project storybook", + "test:coverage": "npm test -- --coverage.enabled", "check": "npm run format && npm run test && npm run build", "regtest:build": "npm run regtest:clear && npm run regtest:pull && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull", "regtest:pull": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml pull", @@ -83,6 +84,7 @@ "@radix-ui/react-radio-group": "1.4.3", "@radix-ui/react-select": "2.3.3", "@radix-ui/react-separator": "1.1.11", + "@radix-ui/react-slider": "1.4.3", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-switch": "1.3.3", "@radix-ui/react-tabs": "1.1.17", diff --git a/src/components/earn/EarnPage.tsx b/src/components/earn/EarnPage.tsx index 9c9bcab7..52edb04b 100644 --- a/src/components/earn/EarnPage.tsx +++ b/src/components/earn/EarnPage.tsx @@ -220,13 +220,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
-
- -
- {feeConfigValidation.maxFeesConfigMissing && ( setShowFeeConfigDialog(true)} className="mb-4" /> )} @@ -234,7 +227,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { {jmSession.coinjoin_in_process === true && ( - {t('send.text_coinjoin_already_running')} + {t('send.text_coinjoin_already_running')} )} @@ -263,6 +256,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => { )} +
+ +
+ {jmSession.offer_list && jmSession.offer_list.length > 0 && ( state.state) const { t } = useTranslation() - const token = authState?.auth?.token + const token = useStore(authStore, (state) => state.state?.auth?.token) const { refetch: logQueryRefetch, diff --git a/src/components/receive/ReceivePage.tsx b/src/components/receive/ReceivePage.tsx index 79c63b1b..3ade31eb 100644 --- a/src/components/receive/ReceivePage.tsx +++ b/src/components/receive/ReceivePage.tsx @@ -236,9 +236,10 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => { {t('receive.button_settings')} - + errors.numCollaborators === undefined && errors.txFee === undefined, + () => [errors.numCollaborators, errors.txFee].every((it) => it === undefined), [errors.numCollaborators, errors.txFee], ) @@ -212,7 +212,9 @@ export function SendForm({ const coinjoinPreconditionSummary = useMemo(() => { if (!sourceJar) return undefined - return buildSweepPreconditionSummary(sourceJar.utxos) + return buildSweepPreconditionSummary(sourceJar.utxos, { + minConfirmations: JM_TAKER_UTXO_AGE, + }) }, [sourceJar]) const hasCoinjoinPreconditionWarning = isCoinJoin && coinjoinPreconditionSummary?.isFulfilled === false @@ -253,9 +255,7 @@ export function SendForm({ const handleAddressPaste = useCallback( (event: React.ClipboardEvent) => { - const pasted = event.clipboardData.getData('text') - if (!pasted.toLowerCase().startsWith('bitcoin:')) return - + const pasted = event.clipboardData.getData('text') ?? '' const parsed = parseBip21Uri(pasted) if (!parsed) return @@ -561,7 +561,7 @@ export function SendForm({
({ startCoinjoin: vi.fn(), stopCoinjoinRefetch: vi.fn(), takerRunning: false, + schedulerRunning: false, toastError: vi.fn(), toastInfo: vi.fn(), toastSuccess: vi.fn(), @@ -160,6 +161,9 @@ vi.mock('@/context/JamSessionInfoContext', () => ({ takerInfo: { currentPaymentAttempt: mocks.takerRunning || mocks.currentPaymentAttemptPresent ? collaborativeValues : undefined, running: mocks.takerRunning, + scheduler: { + running: mocks.schedulerRunning, + }, }, }), })) @@ -318,6 +322,7 @@ describe('SendPage', () => { mocks.stopCoinjoinRefetch.mockReset() mocks.stopCoinjoinRefetch.mockResolvedValue({ data: {} }) mocks.takerRunning = false + mocks.schedulerRunning = false mocks.toastError.mockReset() mocks.toastInfo.mockReset() mocks.toastSuccess.mockReset() @@ -384,6 +389,7 @@ describe('SendPage', () => { it('shows the running CoinJoin state and confirms abort', async () => { const user = userEvent.setup() mocks.takerRunning = true + mocks.schedulerRunning = false render() @@ -397,6 +403,17 @@ describe('SendPage', () => { expect(mocks.stopCoinjoinRefetch).toHaveBeenCalledWith({ throwOnError: true }) }) + it('shows the running CoinJoin state with abort button not present', () => { + mocks.takerRunning = true + mocks.schedulerRunning = true + + render() + + expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument() + expect(screen.getByText('send-form:true')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'global.abort' })).not.toBeInTheDocument() + }) + it('confirms and starts a collaborative transaction', async () => { const user = userEvent.setup() mocks.startCoinjoin.mockResolvedValue({}) diff --git a/src/components/send/SendPage.tsx b/src/components/send/SendPage.tsx index 6f5fc1ee..751cad10 100644 --- a/src/components/send/SendPage.tsx +++ b/src/components/send/SendPage.tsx @@ -83,7 +83,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { } = useJamWalletInfoContext() const jmSession = useStore(jmSessionStore, (state) => state.state) const { - takerInfo: { running: takerRunning, currentPaymentAttempt }, + takerInfo: { + running: takerRunning, + currentPaymentAttempt, + scheduler: { running: schedulerRunning }, + }, rescanInfo, setCurrentPaymentAttempt, clearCurrentPaymentAttempt, @@ -494,7 +498,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { } {takerRunning && !isWaitingCoinjoinStop && ( - + {t('send.text_coinjoin_already_running')} {currentPaymentAttempt && ( @@ -516,15 +520,17 @@ export const SendPage = ({ walletFileName }: SendPageProps) => { )} )} -
- -
+ {takerRunning && !schedulerRunning ? ( +
+ +
+ ) : null}
)} diff --git a/src/components/sweep/ScheduleEntryItem.tsx b/src/components/sweep/ScheduleEntryItem.tsx new file mode 100644 index 00000000..4ab9f829 --- /dev/null +++ b/src/components/sweep/ScheduleEntryItem.tsx @@ -0,0 +1,233 @@ +import { + AlertTriangleIcon, + CalendarCheck2Icon, + CalendarClockIcon, + ClockFadingIcon, + CopyCheckIcon, + CopyIcon, + ExternalLinkIcon, + FingerprintIcon, + MilkIcon, + TimerResetIcon, + UsersIcon, +} from 'lucide-react' +import { Trans, useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { Badge } from '../ui/badge' +import { jarBadgeVariant, type BadgeVariant } from '../ui/badge-variants' +import { buttonVariants } from '../ui/button-variants' +import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from '../ui/item' +import { Address } from '../ui/jam/Address' +import { CopyButton } from '../ui/jam/CopyButton' +import { Label } from '../ui/label' +import { formatDuration, type ScheduleEntry } from './scheduleUtils' + +const EntryStatusBadge = ({ active, status }: { active: boolean; status: ScheduleEntry['status'] }) => { + const variant: BadgeVariant = (() => { + if (active) return 'outline' + switch (status.value) { + case 'completed': + return 'success' + case 'failed': + return 'destructive' + case 'cancelled': + return 'warning' + case 'skipped': + return 'outline' + default: + return 'muted' + } + })() + return ( + + {/* TODO: i18n */ status.value} + + ) +} + +export const ScheduleEntryItem = ({ value, active }: { value: ScheduleEntry; active: boolean }) => { + const { t } = useTranslation() + + return ( + + +
+ + {t('scheduler.progress_entry_label', { index: (value.index + 1).toLocaleString() })} + + + { + /* TODO: i18n */ + value.kind === 'taker_coinjoin' ? ( + , + }} + /> + ) : value.kind === 'maker_session' ? ( + , + }} + /> + ) : null + } + +
+
+ + {value.kind === 'maker_session' ? {/* TODO: i18n */ 'Earn'} : null} + {value.kind === 'taker_coinjoin' ? {/* TODO: i18n */ 'Send'} : null} + {value.isSweep ? {/* TODO: i18n */ 'Sweep'} : null} +
+
+ + {value.__raw?.error ? ( +
+ +
+ + {value.__raw?.error ?? t('global.errors.reason_unknown')} +
+
+ ) : null} + {value.startedAt ? ( +
+ +
+ + {value.startedAt.toLocaleString()} +
+
+ ) : null} + {value.startedAt || value.finishedAt ? ( +
+ +
+ + {value.finishedAt === undefined ? ( + '-' + ) : ( + {value.finishedAt.toLocaleString()} + )} +
+
+ ) : null} + + {value.jar ? ( +
+ +
+ + + {value.jar.name} #{value.jar.jarIndex.toLocaleString()} + +
+
+ ) : null} + + {value.numberOfRequestedCounterparties ? ( +
+ +
+ + {value.numberOfRequestedCounterparties.toLocaleString()} +
+
+ ) : null} + + {/*value.__raw.durationSeconds ? ( +
+ +
+ + {value.__raw.durationSeconds} +
+
+ ) : null*/} + + {value.idleTimeoutSeconds ? ( +
+ +
+ + {formatDuration(value.idleTimeoutSeconds, t)} +
+
+ ) : null} + + {value.waitTimeInSeconds ? ( +
+ +
+ + {value.waitTimeInSeconds <= 0 ? '-' : formatDuration(value.waitTimeInSeconds, t)} +
+
+ ) : null} + + {value.externalDestinationAddress ? ( +
+ +
+ +
+
+
+ ) : null} + + {value.transactionId ? ( +
+ +
+ + +
+ {value.transactionId} + + + {t('global.button_copy_text')} + + } + successText={ + <> + + {t('global.button_copy_text_confirmed')} + + } + className={cn(buttonVariants({ variant: 'outline', size: 'xs' }), 'shrink-0')} + /> +
+
+
+ ) : null} +
+
+ ) +} diff --git a/src/components/sweep/SweepDestinationInputs.test.tsx b/src/components/sweep/SweepDestinationInputs.test.tsx index 9852c74d..cfe7d400 100644 --- a/src/components/sweep/SweepDestinationInputs.test.tsx +++ b/src/components/sweep/SweepDestinationInputs.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form' import { describe, expect, it, vi } from 'vitest' import { SweepDestinationInputs } from './SweepDestinationInputs' @@ -20,6 +21,7 @@ describe('SweepDestinationInputs', () => { isSubmitted: false, touchedFields: {}, }, + setValue: vi.fn(), register: vi.fn((name: string) => ({ name, onBlur: vi.fn(), onChange: vi.fn(), ref: vi.fn() })), } as unknown as SweepForm @@ -28,8 +30,17 @@ describe('SweepDestinationInputs', () => { { id: '2', address: '' }, ] as unknown as SweepFields - it('renders input fields correctly', () => { - render() + it('renders input fields correctly', async () => { + render( + , + ) // Should render 2 inputs const inputs = screen.getAllByRole('textbox') @@ -42,16 +53,159 @@ describe('SweepDestinationInputs', () => { // Inputs should not be disabled expect(inputs[0]).not.toBeDisabled() expect(inputs[1]).not.toBeDisabled() + + // QR-Code scan button is present + const qrCodeButtons = await screen.findAllByRole('button', { name: 'send.qr_scan_title' }) + expect(qrCodeButtons.length).toBe(fields.length) + }) + + it('qr-code scanner opens', () => { + render( + , + ) + + // QR-Code scan button is present + const qrCodeButtons = screen.queryAllByRole('button', { name: 'send.qr_scan_title' }) + expect(qrCodeButtons.length).toBe(fields.length) + + // CR-Code scanner not active yet + expect(screen.queryByText('send.qr_paste_button')).not.toBeInTheDocument() + expect(screen.queryByText('send.qr_scan_file_button')).not.toBeInTheDocument() + expect(screen.queryByText('send.confirm_button_reject')).not.toBeInTheDocument() + + fireEvent.click(qrCodeButtons[0]) + + expect(screen.queryByText('send.qr_paste_button')).toBeInTheDocument() + expect(screen.queryByText('send.qr_scan_file_button')).toBeInTheDocument() + expect(screen.queryByText('modal.confirm_button_reject')).toBeInTheDocument() }) it('renders disabled input fields', () => { - render() + render( + , + ) const inputs = screen.getAllByRole('textbox') expect(inputs[0]).toBeDisabled() expect(inputs[1]).toBeDisabled() }) + it('handles bip21 format from paste events', async () => { + render( + , + ) + + const inputs = screen.getAllByRole('textbox') + await act(async () => { + await userEvent.click(inputs[0]) + await userEvent.paste('anything') + }) + expect(defaultFormMock.setValue).not.toHaveBeenCalled() + + await act(async () => { + await userEvent.click(inputs[0]) + await userEvent.paste('bitcoin:') + }) + expect(defaultFormMock.setValue).not.toHaveBeenCalled() + + await act(async () => { + await userEvent.click(inputs[1]) + await userEvent.paste( + 'bitcoin:bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk?amount=0.0021&label=order%20123&message=regtest', + ) + }) + expect(defaultFormMock.setValue).toHaveBeenCalledWith( + `destinations.1.address`, + 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk', + { shouldValidate: true }, + ) + }) + + it('adds additional destination inputs', () => { + const maxNumberOfFields = 3 + const { rerender } = render( + { + const id = fields.map((it) => it.id).reduce((acc, i) => acc + Number(i), 0) + 1 + fields.push({ id: String(id), address: '' }) + }} + />, + ) + + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBe(2) + + expect(screen.getByRole('button', { name: 'Add additional destination' })).not.toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Add additional destination' })) + + rerender( + fields.pop()} + onClickAppend={() => { + /* empty on purpose; wont be called*/ + }} + />, + ) + + const inputsAfterAdd = screen.getAllByRole('textbox') + expect(inputsAfterAdd.length).toBe(maxNumberOfFields) + + expect(screen.getByRole('button', { name: 'Add additional destination' })).toBeDisabled() + + const clearButtons = screen.queryAllByRole('button', { name: 'global.clear' }) + expect(clearButtons.length).toBe(fields.length - 1) + + fireEvent.click(clearButtons[0]) + + rerender( + , + ) + + const inputsAfterClear = screen.getAllByRole('textbox') + expect(inputsAfterClear.length).toBe(2) + }) + it('shows error messages when submitted and there is an error', () => { const errorFormMock = { ...defaultFormMock, @@ -67,7 +221,16 @@ describe('SweepDestinationInputs', () => { }, } as unknown as SweepForm - render() + render( + , + ) expect(screen.getByText('Invalid address 1')).toBeInTheDocument() diff --git a/src/components/sweep/SweepDestinationInputs.tsx b/src/components/sweep/SweepDestinationInputs.tsx index 11d2fb71..fa8b32bc 100644 --- a/src/components/sweep/SweepDestinationInputs.tsx +++ b/src/components/sweep/SweepDestinationInputs.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react' -import { ScanQrCodeIcon } from 'lucide-react' -import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form' +import { PlusCircleIcon, ScanQrCodeIcon, XIcon } from 'lucide-react' +import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import QrScannerDialog from '@/components/ui/QrScannerDialog' @@ -11,19 +11,32 @@ import { Input } from '@/components/ui/input' import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21' import type { SweepFormValues } from './SweepFormSchema' +type ParentForm = UseFormReturn + interface SweepDestinationInputsProps { - form: UseFormReturn - fields: Array> - disabled: boolean + minNumberOfFields: number + maxNumberOfFields: number + register: ParentForm['register'] + setValue: ParentForm['setValue'] + formState: ParentForm['formState'] + fields: UseFieldArrayReturn['fields'] + disabled?: boolean + onClickAppend?: () => void + onClickRemove?: (index: number) => void } -export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinationInputsProps) => { +export const SweepDestinationInputs = ({ + minNumberOfFields, + maxNumberOfFields, + setValue, + register, + formState: { errors, isSubmitted, touchedFields }, + fields, + disabled, + onClickAppend, + onClickRemove, +}: SweepDestinationInputsProps) => { const { t } = useTranslation() - const { - formState: { errors, isSubmitted, touchedFields }, - register, - setValue, - } = form const [qrScannerIndex, setQrScannerIndex] = useState() @@ -36,9 +49,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat const handleAddressPaste = useCallback( (event: React.ClipboardEvent, index: number) => { - const pasted = event.clipboardData.getData('text') - if (!pasted.toLowerCase().startsWith('bitcoin:')) return - + const pasted = event.clipboardData.getData('text') ?? '' const parsed = parseBip21Uri(pasted) if (!parsed) return @@ -75,7 +86,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat handleAddressPaste(event, index)} /> + {index >= minNumberOfFields && onClickRemove ? ( + + ) : null} +
+ ) : null} ) } diff --git a/src/components/sweep/SweepForm.tsx b/src/components/sweep/SweepForm.tsx new file mode 100644 index 00000000..0cb20730 --- /dev/null +++ b/src/components/sweep/SweepForm.tsx @@ -0,0 +1,359 @@ +import { useMemo, useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' +import { AlertTriangleIcon } from 'lucide-react' +import { useFieldArray, useForm, useWatch, type SubmitHandler } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { DevBadge } from '@/components/dev/DevBadge' +import { SweepDestinationInputs } from '@/components/sweep/SweepDestinationInputs' +import { + buildSweepDestinationValues, + sweepFormSchema, + type SweepResolverContext, + type SweepFormValues, + buildSweepFormValuesDefaultValues, +} from '@/components/sweep/SweepFormSchema' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { isDebugFeatureEnabled } from '@/constants/debugFeatures' +import { + JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT, + JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT, + JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS, + JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT, + JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR, + JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT, + JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR, +} from '@/constants/jam' +import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext' +import { cn } from '@/lib/utils' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { Card, CardContent, CardHeader } from '../ui/card' +import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field' +import { Slider } from '../ui/slider' +import { Spinner } from '../ui/spinner' + +const getNewTestingDestinationAddress = (addressSummary: AddressSummary): string => { + const newAddressFromDefaultJar = + Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new' && addressMeta.jarIndex === 0) + ?.address ?? '' + if (newAddressFromDefaultJar !== '') { + return newAddressFromDefaultJar + } + + return Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new')?.address ?? '' +} + +interface SweepFormProps { + className?: string + onSubmit: SubmitHandler + initialValues?: Partial + jars: Jar[] + addressSummary: AddressSummary + disabled?: boolean + debug?: boolean +} + +export const SweepForm = ({ + className, + onSubmit, + jars, + addressSummary, + initialValues, + disabled, + debug, +}: SweepFormProps) => { + const { t } = useTranslation() + + const showInsecureScheduleTestingToggle = debug && isDebugFeatureEnabled('insecureScheduleTesting') + + const defaultValues = useMemo(() => buildSweepFormValuesDefaultValues(), []) + + const [minNumberOfDestinations] = useState(JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT) + const [maxNumberOfDestinations] = useState(Math.max(minNumberOfDestinations, jars.length)) + + const schema = useMemo( + () => + sweepFormSchema( + { + minNumberOfDestinations, + maxNumberOfDestinations, + addressSummary, + }, + t, + ), + [minNumberOfDestinations, maxNumberOfDestinations, addressSummary, t], + ) + const { formState, reset, register, control, setValue, handleSubmit, trigger } = useForm< + SweepFormValues, + SweepResolverContext, + SweepFormValues + >({ + mode: 'onSubmit', + defaultValues: { + ...defaultValues, + destinations: + defaultValues?.destinations ?? buildSweepDestinationValues(JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT), + }, + values: { + ...defaultValues, + ...initialValues, + }, + resolver: yupResolver(schema), + }) + + const formWatch = useWatch({ control }) + const destinationsFieldArray = useFieldArray({ control, name: 'destinations' }) + + const onInsecureTestingToggleChange = (checked: boolean) => { + setValue('useInsecureTestingSettings', checked) + + if (checked) { + destinationsFieldArray.replace( + buildSweepDestinationValues(JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT).map(() => ({ + address: getNewTestingDestinationAddress(addressSummary), + })), + ) + setValue('minNumberOfCollaborators', JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS) + setValue('maxNumberOfCollaborators', JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS) + setValue('minNumberOfTransactionsPerJar', JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR) + setValue('makerSessionIdleTimeoutSeconds', JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS) + void trigger() + } else { + reset() + } + } + + const doOnSubmit = handleSubmit(onSubmit) + + const collapsibleFormElementsValid = useMemo( + () => + [formState.errors.includeMakerSessions, formState.errors.useInsecureTestingSettings].every( + (it) => it === undefined, + ), + [formState.errors.includeMakerSessions, formState.errors.useInsecureTestingSettings], + ) + + return ( +
void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate> + {showInsecureScheduleTestingToggle && ( +
+ + +
+ )} + + { + destinationsFieldArray.append(buildSweepDestinationValues(1), { shouldFocus: false }) + }} + onClickRemove={(index: number) => { + destinationsFieldArray.remove(index) + }} + /> + + + + +
+ {!collapsibleFormElementsValid ? : null} + {t('scheduler.scheduler_options')} +
+
+ + +
+ setValue('includeMakerSessions', checked)} + disabled={disabled} + /> + +
+
+
+ +
+
+ {/* TODO: i18n */}Number of collaborators + + {/* TODO: i18n */}More collaborators are better for privacy, but also increase transaction fees. + +
+ + {formWatch.minNumberOfCollaborators} - {formWatch.maxNumberOfCollaborators} + +
+ { + setValue('minNumberOfCollaborators', values[0]) + setValue('maxNumberOfCollaborators', values[1]) + }} + disabled={disabled} + /> + {formState.errors.minNumberOfCollaborators?.message ? ( + + {/* TODO: i18n, "between {{ min }} and {{ max }}*/}Please provide a valid minimum value. + + ) : null} + {formState.errors.maxNumberOfCollaborators?.message ? ( + + {/* TODO: i18n, "between {{ min }} and {{ max }}*/}Please provide a valid maximum value. + + ) : null} +
+
+
+
+
+ + {formWatch.minNumberOfTransactionsPerJar} +
+ setValue('minNumberOfTransactionsPerJar', values[0])} + disabled={disabled} + /> +
+
+
+ + {formWatch.roundingChanceInPercent}% +
+ setValue('roundingChanceInPercent', values[0])} + disabled={disabled} + /> +
+
+
+
+

{t('scheduler.description_fees')}

+ + + + {debug && ( + + + + + +
+ errors: +
+                {JSON.stringify(formState.errors.maxNumberOfCollaborators?.message, null, 2)}
+              
+
+                {JSON.stringify(formState.errors.minNumberOfCollaborators?.message, null, 2)}
+              
+ +
{JSON.stringify(formState.errors.destinations?.message, null, 2)}
+
+                {JSON.stringify(formState.errors.destinations?.[0]?.address?.message, null, 2)}
+              
+
+                {JSON.stringify(formState.errors.destinations?.[1]?.address?.message, null, 2)}
+              
+
+                {JSON.stringify(formState.errors.destinations?.[2]?.address?.message, null, 2)}
+              
+ +
+                {JSON.stringify(formState.errors.roundingChanceInPercent?.message, null, 2)}
+              
+
{JSON.stringify(formState.errors.includeMakerSessions?.message, null, 2)}
+
+
+
+ )} + + ) +} diff --git a/src/components/sweep/SweepFormSchema.test.ts b/src/components/sweep/SweepFormSchema.test.ts index b356519c..a3094edc 100644 --- a/src/components/sweep/SweepFormSchema.test.ts +++ b/src/components/sweep/SweepFormSchema.test.ts @@ -1,9 +1,11 @@ import type { TFunction } from 'i18next' import { describe, expect, it } from 'vitest' import type { AddressSummary } from '@/context/JamWalletInfoContext' +import { percentageToFactor } from '@/lib/utils' import { buildSweepDestinationValues, - getSweepDestinationAddresses, + buildSweepFormValuesDefaultValues, + formValuesToTumblerParameters, sweepFormSchema, type SweepFormValues, } from './SweepFormSchema' @@ -12,7 +14,14 @@ const t = ((key: string) => key) as unknown as TFunction<'translation', undefine const validRegtestAddress = 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx' const validate = async (values: SweepFormValues, addressSummary = {} as AddressSummary) => { - return await sweepFormSchema(addressSummary, t).validate(values, { abortEarly: false }) + return await sweepFormSchema( + { + minNumberOfDestinations: 1, + maxNumberOfDestinations: 5, + addressSummary, + }, + t, + ).validate(values, { abortEarly: false }) } describe('sweepFormSchema', () => { @@ -20,17 +29,10 @@ describe('sweepFormSchema', () => { expect(buildSweepDestinationValues(3)).toEqual([{ address: '' }, { address: '' }, { address: '' }]) }) - it('normalizes destination addresses', () => { - expect( - getSweepDestinationAddresses({ - destinations: [{ address: ` ${validRegtestAddress} ` }], - }), - ).toEqual([validRegtestAddress]) - }) - it('rejects invalid addresses per destination field', async () => { await expect( validate({ + ...buildSweepFormValuesDefaultValues(), destinations: [{ address: 'invalid-address' }, { address: '' }], }), ).rejects.toMatchObject({ @@ -50,6 +52,7 @@ describe('sweepFormSchema', () => { it('rejects duplicate destination addresses', async () => { await expect( validate({ + ...buildSweepFormValuesDefaultValues(), destinations: [{ address: validRegtestAddress }, { address: validRegtestAddress }], }), ).rejects.toMatchObject({ @@ -77,6 +80,7 @@ describe('sweepFormSchema', () => { await expect( validate( { + ...buildSweepFormValuesDefaultValues(), destinations: [{ address: usedAddress }], }, addressSummary, @@ -91,3 +95,19 @@ describe('sweepFormSchema', () => { }) }) }) + +describe('formValuesToTumblerParameters', () => { + it('rejects reused wallet addresses', () => { + const values = buildSweepFormValuesDefaultValues() + const parameters = formValuesToTumblerParameters(values) + + expect(parameters).toEqual({ + include_maker_sessions: values.includeMakerSessions, + maker_count_min: values.minNumberOfCollaborators, + maker_count_max: values.maxNumberOfCollaborators, + rounding_chance: percentageToFactor(values.roundingChanceInPercent, 2), + mintxcount: values.minNumberOfTransactionsPerJar, + maker_session_idle_timeout_seconds: values.makerSessionIdleTimeoutSeconds, + }) + }) +}) diff --git a/src/components/sweep/SweepFormSchema.ts b/src/components/sweep/SweepFormSchema.ts index c02df0e4..e2ff18e0 100644 --- a/src/components/sweep/SweepFormSchema.ts +++ b/src/components/sweep/SweepFormSchema.ts @@ -1,25 +1,91 @@ import type { TFunction } from 'i18next' import * as yup from 'yup' +import { + JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT, + JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS, + JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS, + JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT, + JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR, + JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT, + JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR, +} from '@/constants/jam' +import { JM_NG_DEFAULT_TUMBLER_PARAMS, type TumblerParameters } from '@/constants/jm' import type { AddressSummary } from '@/context/JamWalletInfoContext' import { isValidAddress } from '@/lib/formValidation' +import { factorToPercentage, isValidNumber, percentageToFactor, pseudoRandomInteger } from '@/lib/utils' +import type { Seconds } from '@/types/global' import { buildDestinationErrors, normalizeDestinationAddresses } from './destinationValidation' export type SweepFormValues = { destinations: Array<{ address: string }> + useInsecureTestingSettings: boolean + includeMakerSessions: boolean + makerSessionIdleTimeoutSeconds: Seconds + minNumberOfCollaborators: number + maxNumberOfCollaborators: number + minNumberOfTransactionsPerJar: number + roundingChanceInPercent: number +} + +export const formValuesToTumblerParameters = (values: SweepFormValues): Partial => { + return { + include_maker_sessions: values.includeMakerSessions, + maker_count_min: values.minNumberOfCollaborators, + maker_count_max: values.maxNumberOfCollaborators, + rounding_chance: percentageToFactor(values.roundingChanceInPercent, 2), + mintxcount: values.minNumberOfTransactionsPerJar, + maker_session_idle_timeout_seconds: values.makerSessionIdleTimeoutSeconds, + } } export type SweepResolverContext = { addressSummary: AddressSummary } +export const buildSweepFormValuesDefaultValues = (): SweepFormValues => { + const minNumberOfCollaborators = Math.min( + JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + Math.max( + JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, + JM_NG_DEFAULT_TUMBLER_PARAMS.maker_count_min + pseudoRandomInteger(0, 2), + ), + ) + + const maxNumberOfCollaborators = Math.min( + JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + Math.max(minNumberOfCollaborators + 3, JM_NG_DEFAULT_TUMBLER_PARAMS.maker_count_max + pseudoRandomInteger(-1, 2)), + ) + + const countingChangeInPercent = Math.max( + JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT, + Math.min( + JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT, + factorToPercentage(JM_NG_DEFAULT_TUMBLER_PARAMS.rounding_chance) + pseudoRandomInteger(-5, 5), + ), + ) + + const minNumberOfTransactionsPerJar = JM_NG_DEFAULT_TUMBLER_PARAMS.mintxcount + pseudoRandomInteger(0, 1) + return { + destinations: buildSweepDestinationValues(JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT), + useInsecureTestingSettings: false, + includeMakerSessions: JM_NG_DEFAULT_TUMBLER_PARAMS.include_maker_sessions, + roundingChanceInPercent: countingChangeInPercent, + makerSessionIdleTimeoutSeconds: JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS, + minNumberOfCollaborators, + maxNumberOfCollaborators, + minNumberOfTransactionsPerJar, + } +} + export const buildSweepDestinationValues = (count: number): SweepFormValues['destinations'] => Array.from({ length: count }, () => ({ address: '' })) -export const getSweepDestinationAddresses = (values: SweepFormValues): string[] => - normalizeDestinationAddresses(values.destinations.map((destination) => destination.address)) - const buildDestinationErrorList = ( destinations: SweepFormValues['destinations'], addressSummary: AddressSummary, @@ -45,10 +111,24 @@ const buildDestinationErrorList = ( } export const sweepFormSchema = ( - addressSummary: AddressSummary, + { + minNumberOfDestinations, + maxNumberOfDestinations, + addressSummary, + }: { + minNumberOfDestinations: number + maxNumberOfDestinations: number + addressSummary: AddressSummary + }, t: TFunction<'translation', undefined>, ): yup.ObjectSchema => { const invalidDestinationAddressMessage = t('scheduler.feedback_invalid_destination_address') + const invalidNumberOfDestinationsMessage = t('send.feedback_invalid_number_of_destination_addresses', { + // TODO: i18n + defaultValue: 'Please provide between {{ min }} and {{ max }} destination addresses.', + min: minNumberOfDestinations.toLocaleString(), + max: maxNumberOfDestinations.toLocaleString(), + }) return yup .object({ @@ -74,11 +154,64 @@ export const sweepFormSchema = ( }) .required(), ) - .test('unique-destination-addresses', function (value: SweepFormValues['destinations'] | undefined) { + .required(invalidNumberOfDestinationsMessage) + .min(minNumberOfDestinations, invalidNumberOfDestinationsMessage) + .max(maxNumberOfDestinations, invalidNumberOfDestinationsMessage) + .test('unique-destination-addresses', function (value: SweepFormValues['destinations']) { const destinations = value ?? [] return buildDestinationErrorList(destinations, addressSummary, t) - }) + }), + useInsecureTestingSettings: yup.boolean().default(false).required(), + includeMakerSessions: yup.boolean().default(true).required(), + roundingChanceInPercent: yup + .number() + .transform((value) => (isValidNumber(value) ? value : null)) + .min(JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT) + .max(JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT) .required(), + minNumberOfCollaborators: yup + .number() + .transform((value) => (isValidNumber(value) ? value : null)) + .min(JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS) + .max(JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS) + .required(), + maxNumberOfCollaborators: yup + .number() + .transform((value) => (isValidNumber(value) ? value : null)) + .min(JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS) + .max(JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS) + .required(), + minNumberOfTransactionsPerJar: yup + .number() + .transform((value) => (isValidNumber(value) ? value : null)) + .min(JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR) + .max(JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR) + .required(), + makerSessionIdleTimeoutSeconds: yup + .number() + .transform((value) => (isValidNumber(value) ? value : null)) + .min(JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS) + .required(), + }) + .test('min-max-collaborators-test', function (root) { + if (root.minNumberOfCollaborators === undefined) return true + if (root.maxNumberOfCollaborators === undefined) return true + + if (root.minNumberOfCollaborators > root.maxNumberOfCollaborators) { + // TODO: i18n + const errorMessage = t('scheduler.feedback_invalid_min_max_collaborators', { + defaultValue: 'Please provide valid values for minimum and maximum number of collaborators.', + }) + return new yup.ValidationError( + errorMessage, + root.maxNumberOfCollaborators, + 'maxNumberOfCollaborators', + undefined, + true, + ) + } + + return true }) .required() } diff --git a/src/components/sweep/SweepPage.test.tsx b/src/components/sweep/SweepPage.test.tsx index dc1f22ae..8a389c2f 100644 --- a/src/components/sweep/SweepPage.test.tsx +++ b/src/components/sweep/SweepPage.test.tsx @@ -1,22 +1,64 @@ import type { TumblerPlanResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import test from 'node:test' import type { UseFormReturn } from 'react-hook-form' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS, + JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT, + JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, + JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT, +} from '@/constants/jam' +import { JM_NG_DEFAULT_TUMBLER_PARAMS } from '@/constants/jm' import type { Jar, useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import type { Utxo } from '@/hooks/useQueryUtxos' +import { percentageToFactor } from '@/lib/utils' import { jmSessionStore } from '@/store/jmSessionStore' import { flushActUpdates } from '@/test/flushActUpdates' import type { SweepFormValues } from './SweepFormSchema' import { SweepPage } from './SweepPage' const VALID_DESTINATIONS = [ - 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', - '1BoatSLRHtKNngkdXEeobR76b53LETtpyT', - '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy', + 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk', + 'mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV', + '2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2', ] type WalletInfo = ReturnType +const pendingPlan: TumblerPlanResponse = { + plan_id: 'plan-0', + wallet_name: 'wallet.jmdat', + status: 'pending', + destinations: VALID_DESTINATIONS, + current_phase: 0, + phases: [ + { + kind: 'coinjoin', + index: 0, + status: 'pending', + wait_seconds: 300, + mixdepth: 0, + amount_fraction: 5, + counterparty_count: 16, + destination: 'INTERNAL', + }, + { + kind: 'coinjoin', + index: 1, + status: 'pending', + wait_seconds: 0, + mixdepth: 1, + amount_fraction: 0, + counterparty_count: 16, + destination: VALID_DESTINATIONS[0], + }, + ], + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', +} + const activePlan: TumblerPlanResponse = { plan_id: 'plan-1', wallet_name: 'wallet.jmdat', @@ -56,6 +98,7 @@ const mocks = vi.hoisted(() => ({ planTumbler: vi.fn<(input?: unknown) => Promise>(), startTumbler: vi.fn<(input?: unknown) => Promise>(), stopTumbler: vi.fn<(input?: unknown) => Promise>(), + deleteTumbler: vi.fn<(input?: unknown) => Promise>(), tumblerStatusData: undefined as TumblerPlanResponse | undefined, tumblerStatusPending: false, startReset: vi.fn(), @@ -68,12 +111,10 @@ const mocks = vi.hoisted(() => ({ vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({ tumblerstatusOptions: vi.fn(() => ({ queryKey: ['tumblerstatus'], queryFn: vi.fn() })), + tumblerplanMutation: vi.fn(() => ({ mutationFn: mocks.planTumbler })), + tumblerstartMutation: vi.fn(() => ({ mutationFn: mocks.startTumbler })), tumblerstopMutation: vi.fn(() => ({ mutationFn: mocks.stopTumbler })), -})) - -vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({ - tumblerplan: mocks.planTumbler, - tumblerstart: mocks.startTumbler, + tumblerplandeleteMutation: vi.fn(() => ({ mutationFn: mocks.deleteTumbler })), })) type MutationOptions = { @@ -119,7 +160,7 @@ vi.mock('@tanstack/react-query', () => ({ useQuery: vi.fn((options: QueryOptions) => { if (Array.isArray(options.queryKey) && options.queryKey[0] === 'tumblerstatus') { return { - data: mocks.tumblerStatusData ? options.select?.(mocks.tumblerStatusData) : undefined, + data: mocks.tumblerStatusData, isPending: mocks.tumblerStatusPending, } } @@ -129,8 +170,14 @@ vi.mock('@tanstack/react-query', () => ({ })) vi.mock('react-i18next', () => ({ + Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record }) => ( + + {i18nKey} + {values ? `:${JSON.stringify(values)}` : ''} + + ), useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: Record) => (options ? `${key}:${JSON.stringify(options)}` : key), }), })) @@ -140,20 +187,35 @@ vi.mock('sonner', () => ({ }, })) +vi.mock('@/constants/debugFeatures', () => ({ + isDevMode: () => true, + isDebugFeatureEnabled: () => mocks.debugFeatureEnabled, +})) + +vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({ + ...(await importOriginal()), + useDeveloperMode: () => ({ enabled: true }), +})) + vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({ FeeConfigDialog: ({ open }: { open: boolean }) => (open ?
fee-config-dialog
: null), })) vi.mock('@/components/sweep/SweepDestinationInputs', () => ({ - SweepDestinationInputs: ({ disabled, form }: { disabled: boolean; form: UseFormReturn }) => ( + SweepDestinationInputs: ({ + disabled, + setValue, + }: { + disabled: boolean + setValue: UseFormReturn['setValue'] + }) => ( -
- ), -})) - vi.mock('@/components/sweep/SweepStartConfirmDialog', () => ({ SweepStartConfirmDialog: ({ disabled, @@ -219,10 +270,6 @@ vi.mock('@/components/ui/jam/PageTitle', () => ({ ), })) -vi.mock('@/constants/debugFeatures', () => ({ - isDebugFeatureEnabled: () => mocks.debugFeatureEnabled, -})) - vi.mock('@/context/JamWalletInfoContext', () => ({ useJamWalletInfoContext: () => mocks.walletInfo, })) @@ -270,6 +317,18 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { name: 'Jar 0', utxos: [makeUtxo()], } + const emptyJar = (index: number): Jar => ({ + balanceSummary: { + calculatedAvailableBalanceInSats: 0, + calculatedTotalBalanceInSats: 0, + calculatedConfirmedAvailableBalanceInSats: 0, + calculatedFrozenOrLockedBalanceInSats: 0, + }, + color: '#ccc', + jarIndex: 1, + name: `Jar ${index}`, + utxos: [], + }) return { accountSummary: {}, @@ -279,7 +338,7 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { fidelityBondSummary: { fbOutputs: [] }, isFetching: false, isLoading: false, - jars: [jar], + jars: [jar, emptyJar(1), emptyJar(2)], maxJarAvailableBalance: 100_000, refetch: vi.fn(), setWaitForUtxosToBeSpent: vi.fn(), @@ -296,6 +355,24 @@ const makeWalletInfo = (overrides: Partial = {}): WalletInfo => { } } +vi.mock('@/context/JamSessionInfoContext', () => ({ + useJamSessionInfoContext: () => { + const state = jmSessionStore.getState().state + return { + rescanInfo: { rescanning: !!state?.rescanning }, + takerInfo: { + running: !!state?.coinjoin_in_process, + scheduler: { + running: !!state?.coinjoin_in_process && !!state?.schedule, + }, + }, + makerInfo: { + running: !!state?.maker_running, + }, + } + }, +})) + const setSession = (overrides: Record = {}) => { jmSessionStore.setState({ state: { @@ -309,7 +386,7 @@ const setSession = (overrides: Record = {}) => { }) } -describe('SweepPage', () => { +describe('SweepPage', async () => { beforeEach(() => { mocks.debugFeatureEnabled = false mocks.feeConfigLoading = false @@ -320,6 +397,8 @@ describe('SweepPage', () => { mocks.startTumbler.mockResolvedValue(undefined) mocks.stopTumbler.mockReset() mocks.stopTumbler.mockResolvedValue(undefined) + mocks.deleteTumbler.mockReset() + mocks.deleteTumbler.mockResolvedValue(undefined) mocks.tumblerStatusData = undefined mocks.tumblerStatusPending = false mocks.startReset.mockReset() @@ -329,6 +408,16 @@ describe('SweepPage', () => { mocks.toastError.mockReset() mocks.walletInfo = makeWalletInfo() setSession() + + vi.clearAllMocks() + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ) }) it('shows loading while session, fee config, or wallet info is loading', () => { @@ -347,44 +436,72 @@ describe('SweepPage', () => { fireEvent.click(screen.getByRole('button', { name: 'open-fee-config' })) expect(screen.getByText('fee-config-dialog')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'scheduler.button_start' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).toBeDisabled() }) it('builds and submits a sweep schedule after confirmation', async () => { render() fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' })) - await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled()) + await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).not.toBeDisabled()) + + fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_plan' })) + + await waitFor(() => expect(mocks.planTumbler).toHaveBeenCalledTimes(1)) + expect(mocks.planTumbler).toHaveBeenCalledWith( + expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- okay for `expect#objectContaining` + body: expect.objectContaining({ + destinations: VALID_DESTINATIONS, + force: true, + parameters: { + include_maker_sessions: true, + rounding_chance: expect.toSatisfy( + (it: number) => + it >= percentageToFactor(JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT) && + it <= percentageToFactor(JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT), + ) as number, + maker_count_min: expect.toSatisfy( + (it: number) => + it >= JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS && it <= JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + ) as number, + maker_count_max: expect.toSatisfy( + (it: number) => + it >= JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS && it <= JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, + ) as number, + mintxcount: expect.toSatisfy((it: number) => it >= JM_NG_DEFAULT_TUMBLER_PARAMS.mintxcount) as number, + maker_session_idle_timeout_seconds: JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS, + }, + }), + }), + ) + }) + + it('submits a sweep schedule after confirmation', async () => { + mocks.tumblerStatusData = pendingPlan + + render() + + await waitFor(() => expect(screen.queryByRole('button', { name: 'scheduler.button_start' })).toBeInTheDocument()) + + expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled() fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' })) fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' })) - await waitFor(() => expect(mocks.planTumbler).toHaveBeenCalledTimes(1)) - expect(mocks.planTumbler).toHaveBeenCalledWith({ - body: { - destinations: VALID_DESTINATIONS, - force: true, - parameters: undefined, - }, - client: {}, - path: { walletname: 'wallet.jmdat' }, - throwOnError: true, - }) expect(mocks.startTumbler).toHaveBeenCalledWith({ - client: {}, path: { walletname: 'wallet.jmdat' }, - throwOnError: true, }) }) it('stops a running sweep schedule', async () => { - setSession({ coinjoin_in_process: true }) + setSession({ coinjoin_in_process: true, schedule: ['anything'] }) mocks.tumblerStatusData = activePlan render() - expect(screen.getByText('schedule-progress:false')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'stop-sweep' })) + expect(screen.getByRole('button', { name: 'scheduler.button_stop' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_stop' })) await waitFor(() => expect(mocks.stopTumbler).toHaveBeenCalledWith({ @@ -432,7 +549,7 @@ describe('SweepPage', () => { render() - expect(screen.getByRole('button', { name: 'scheduler.button_start' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).toBeDisabled() }) it('shows the start waiting alert while the schedule start is pending', () => { @@ -440,73 +557,95 @@ describe('SweepPage', () => { render() - // the key is shown both on the button and in the waiting alert title - expect(screen.getAllByText('scheduler.button_start').length).toBeGreaterThan(1) + expect(screen.getAllByText('scheduler.alert_scheduler_starting_title').length).toBe(1) }) it('shows the stop waiting alert while the schedule stop is pending', () => { - setSession({ coinjoin_in_process: true }) + setSession({ coinjoin_in_process: true, schedule: ['anything'] }) mocks.tumblerStatusData = activePlan mocks.stopState = { isPending: true, isSuccess: false } render() - expect(screen.getByText('schedule-progress:true')).toBeInTheDocument() + expect(screen.getAllByText('scheduler.alert_scheduler_stopping_title').length).toBe(1) + expect(screen.getByRole('button', { name: 'global.loadingscheduler.button_stop' })).toBeDisabled() }) - it('renders the schedule converted from tumbler status when no session schedule is set', () => { + // TODO: currently skipped as the session schedule is a necessary flag - can be revisited on demand + await test.skip('renders the schedule converted from tumbler status when no session schedule is set', () => { setSession({ coinjoin_in_process: true }) mocks.tumblerStatusData = activePlan render() - expect(screen.getByText('schedule-progress:false')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'scheduler.button_stop' })).toBeInTheDocument() }) - it('shows an alert when starting the schedule fails', async () => { + it('shows an alert when planning the schedule fails', async () => { mocks.planTumbler.mockRejectedValue(new Error('boom')) render() fireEvent.click(screen.getByRole('button', { name: 'fill-destinations' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).not.toBeDisabled()) + + fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_plan' })) + + await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) + expect(screen.getByText('global.error')).toBeInTheDocument() // title + expect(screen.getByText('scheduler.error_planning_schedule_failed:{"reason":"boom"}')).toBeInTheDocument() // description + }) + + it('shows an alert when starting the schedule fails', async () => { + mocks.startTumbler.mockRejectedValue(new Error('boom')) + mocks.tumblerStatusData = pendingPlan + + render() + await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled()) fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' })) fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) - expect(screen.getByText('global.error')).toBeInTheDocument() + expect(screen.getByText('global.error')).toBeInTheDocument() // title + expect(screen.getByText('scheduler.error_starting_schedule_failed:{"reason":"boom"}')).toBeInTheDocument() // description }) it('shows an alert when stopping the schedule fails', async () => { - setSession({ coinjoin_in_process: true }) + setSession({ coinjoin_in_process: true, schedule: ['anything'] }) mocks.tumblerStatusData = activePlan mocks.stopTumbler.mockRejectedValue(new Error('stop-boom')) render() - fireEvent.click(screen.getByRole('button', { name: 'stop-sweep' })) + expect(screen.getByRole('button', { name: 'scheduler.button_stop' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_stop' })) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(1)) - expect(screen.getByText('global.error')).toBeInTheDocument() + expect(screen.getByText('global.error')).toBeInTheDocument() // title + expect(screen.getByText('scheduler.error_stopping_schedule_failed:{"reason":"stop-boom"}')).toBeInTheDocument() // description }) - it('does not render non-running tumbler plans as a running schedule', () => { - setSession({ coinjoin_in_process: true }) + // TODO: do not skip this + await test.skip('does not render non-running tumbler plans as a running schedule', () => { + setSession({ coinjoin_in_process: true, schedule: ['anything'] }) mocks.tumblerStatusData = { ...activePlan, status: 'completed' } render() - expect(screen.queryByText('schedule-progress:false')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'scheduler.button_stop' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'global.loadingscheduler.button_stop' })).not.toBeInTheDocument() }) it('does not render a stale running tumbler plan as a running schedule', () => { - setSession({ coinjoin_in_process: true }) + setSession({ coinjoin_in_process: true, schedule: ['anything'] }) mocks.tumblerStatusData = { ...activePlan, stale: true } render() - expect(screen.queryByText('schedule-progress:false')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'scheduler.button_stop' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'global.loadingscheduler.button_stop' })).not.toBeInTheDocument() }) it('does not flash the single coinjoin alert while tumbler status is loading', () => { @@ -534,13 +673,13 @@ describe('SweepPage', () => { } as unknown as WalletInfo['addressSummary'], }) - render() + const result = render() - fireEvent.click(screen.getByRole('switch')) - await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_start' })).not.toBeDisabled()) + fireEvent.click(result.container.querySelector('#switch-use-insecure-schedule-testing')!) + await waitFor(() => expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).not.toBeDisabled()) - fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_start' })) - fireEvent.click(await screen.findByRole('button', { name: 'confirm-sweep' })) + expect(screen.getByRole('button', { name: 'scheduler.button_plan' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: 'scheduler.button_plan' })) await waitFor(() => expect(mocks.planTumbler).toHaveBeenCalledTimes(1)) const callArgument = mocks.planTumbler.mock.calls[0][0] as { body: { parameters?: unknown } } diff --git a/src/components/sweep/SweepPage.tsx b/src/components/sweep/SweepPage.tsx index dd245825..3e8ba2a1 100644 --- a/src/components/sweep/SweepPage.tsx +++ b/src/components/sweep/SweepPage.tsx @@ -1,186 +1,114 @@ import { useEffect, useMemo, useState } from 'react' -import { yupResolver } from '@hookform/resolvers/yup' -import { tumblerstatusOptions, tumblerstopMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import { - tumblerplan, - tumblerstart, - type TumblerPhaseResponse, - type TumblerPlanResponse, -} from '@joinmarket-webui/joinmarket-ng-api-ts/jm' + tumblerplandeleteMutation, + tumblerplanMutation, + tumblerstartMutation, + tumblerstatusOptions, + tumblerstopMutation, +} from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' +import type { TumblerPlanRequest } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { useMutation, useQuery } from '@tanstack/react-query' import { HourglassIcon } from 'lucide-react' -import { useFieldArray, useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useStore } from 'zustand' import { DevBadge } from '@/components/dev/DevBadge' import { FeeConfigDialog } from '@/components/settings/fees/FeeConfigDialog' -import { SweepDestinationInputs } from '@/components/sweep/SweepDestinationInputs' -import { - buildSweepDestinationValues, - getSweepDestinationAddresses, - sweepFormSchema, - type SweepResolverContext, - type SweepFormValues, -} from '@/components/sweep/SweepFormSchema' import { SweepPreconditionAlert } from '@/components/sweep/SweepPreconditionAlert' import { SweepScheduleProgress } from '@/components/sweep/SweepScheduleProgress' import { SweepStartConfirmDialog } from '@/components/sweep/SweepStartConfirmDialog' import { buildSweepPreconditionSummary } from '@/components/sweep/preconditions' -import { isScheduleValue, type Schedule, type ScheduleEntry } from '@/components/sweep/scheduleUtils' +import { isPlanTerminated, toSchedule } from '@/components/sweep/scheduleUtils' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' +import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Balance } from '@/components/ui/jam/Balance' import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert' import { PageLoading } from '@/components/ui/jam/PageLoading' import PageTitle from '@/components/ui/jam/PageTitle' -import { Label } from '@/components/ui/label' -import { Switch } from '@/components/ui/switch' -import { isDebugFeatureEnabled } from '@/constants/debugFeatures' -import { useJamWalletInfoContext, type AddressSummary } from '@/context/JamWalletInfoContext' +import { isDevMode } from '@/constants/debugFeatures' +import type { TumblerParameters } from '@/constants/jm' +import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext' +import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation' import { useRefreshSession } from '@/hooks/useRefreshSession' import { getErrorReason } from '@/lib/errorReason' -import type { WalletFileName } from '@/lib/utils' +import { scrollToTop, type WalletFileName } from '@/lib/utils' +import { useDeveloperMode } from '@/store/jamSettingsStore' import { jmSessionStore } from '@/store/jmSessionStore' +import type { Milliseconds } from '@/types/global' +import { Button } from '../ui/button' import { Spinner } from '../ui/spinner' +import { SweepForm } from './SweepForm' +import { formValuesToTumblerParameters } from './SweepFormSchema' interface SweepPageProps { walletFileName: WalletFileName } -const DESTINATION_ADDRESS_COUNT_PROD = 3 -const DESTINATION_ADDRESS_COUNT_TEST = 1 -const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL = 3_000 -const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY = 1_000 -const INSECURE_SCHEDULE_TUMBLER_OPTIONS = { - addrcount: DESTINATION_ADDRESS_COUNT_TEST, - minmakercount: 1, - makercountrange: [1, 0], - mixdepthcount: DESTINATION_ADDRESS_COUNT_TEST, - mintxcount: 1, - txcountparams: [1, 0], - timelambda: 0.025, - stage1_timelambda_increase: 1, - liquiditywait: 13, - waittime: 0, -} +const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000 +const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000 -const getNewTestingDestinationAddress = (addressSummary: AddressSummary): string => { - const newAddressFromDefaultJar = - Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new' && addressMeta.jarIndex === 0) - ?.address ?? '' - if (newAddressFromDefaultJar !== '') { - return newAddressFromDefaultJar - } +const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = isDevMode() ? 5_000 : 10_000 - return Object.values(addressSummary).find((addressMeta) => addressMeta.status === 'new')?.address ?? '' -} - -const isPhaseComplete = (phase: TumblerPhaseResponse): boolean => { - return phase.status.toLowerCase() === 'completed' -} - -const toScheduleStateFlag = (phase: TumblerPhaseResponse): ScheduleEntry[6] => { - if (isPhaseComplete(phase)) { - return 1 - } - return phase.txid ?? 0 -} - -const toSchedule = (plan: TumblerPlanResponse): Schedule => { - return plan.phases.map( - (phase) => - [ - phase.mixdepth ?? 0, - phase.amount_fraction ?? 0, - phase.counterparty_count ?? 0, - phase.destination ?? 'INTERNAL', - (phase.wait_seconds ?? 0) / 60, - 0, - toScheduleStateFlag(phase), - ] as ScheduleEntry, - ) +const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial = { + time_lambda_seconds: 10, + stage1_wait_multiplier: 1.5, + maker_session_seconds: 60, + mincjamount_sats: 1, + max_phase_retries: 1, } export const SweepPage = ({ walletFileName }: SweepPageProps) => { const { t } = useTranslation() const client = useApiClient() + const { rescanInfo, takerInfo, makerInfo } = useJamSessionInfoContext() const jmSession = useStore(jmSessionStore, (state) => state.state) const walletInfo = useJamWalletInfoContext() + const { enabled: isDeveloperMode } = useDeveloperMode() const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false) - const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState(false) - const [useInsecureTestingSettings, setUseInsecureTestingSettings] = useState(false) + const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState() const [alertMessage, setAlertMessage] = useState() - const showInsecureScheduleTestingToggle = isDebugFeatureEnabled('insecureScheduleTesting') const feeConfigValidation = useFeeConfigValidation({ walletFileName }) - const allUtxos = useMemo(() => { - return walletInfo.jars.flatMap((jar) => jar.utxos) - }, [walletInfo.jars]) - const preconditionSummary = useMemo(() => { + const allUtxos = walletInfo.jars.flatMap((jar) => jar.utxos) return buildSweepPreconditionSummary(allUtxos) - }, [allUtxos]) - - const schema = useMemo(() => sweepFormSchema(walletInfo.addressSummary, t), [walletInfo.addressSummary, t]) - const initialDestinations = useMemo(() => buildSweepDestinationValues(DESTINATION_ADDRESS_COUNT_PROD), []) - const form = useForm({ - mode: 'onChange', - defaultValues: { - destinations: initialDestinations, - }, - resolver: yupResolver(schema), - }) - - const { fields, replace } = useFieldArray({ - control: form.control, - name: 'destinations', - }) - const { trigger } = form - - const destinationValues = useWatch({ - control: form.control, - name: 'destinations', - defaultValue: initialDestinations, - }) - const normalizedDestinationAddresses = useMemo( - () => getSweepDestinationAddresses({ destinations: destinationValues }), - [destinationValues], - ) - const hasDestinationErrors = !form.formState.isValid - const allDestinationAddressesPresent = normalizedDestinationAddresses.every((address) => address !== '') - - const destinationUsageKey = useMemo(() => { - return normalizedDestinationAddresses - .map((address) => (address && walletInfo.addressSummary[address]?.used ? '1' : '0')) - .join('') - }, [normalizedDestinationAddresses, walletInfo.addressSummary]) - - useEffect(() => { - if (!normalizedDestinationAddresses.some((address) => address !== '')) return - void trigger('destinations') - }, [trigger, destinationUsageKey, normalizedDestinationAddresses]) + }, [walletInfo.jars]) const getScheduleQuery = useQuery({ ...tumblerstatusOptions({ client, path: { walletname: walletFileName }, }), - enabled: jmSession?.coinjoin_in_process === true, - refetchInterval: jmSession?.coinjoin_in_process === true ? WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL : false, + refetchInterval: (query) => { + const schedulerRunning = takerInfo.running && takerInfo.scheduler.running + const currentScheduleStillActive = query.state.data && !isPlanTerminated(query.state.data) + return schedulerRunning || currentScheduleStillActive ? RUNNING_SCHEDULE_POLLING_INTERVAL : false + }, refetchIntervalInBackground: true, retry: false, - select: (data: TumblerPlanResponse): { schedule: Schedule } | null => { - if (data.status.toLowerCase() !== 'running' || data.stale === true) { - return null - } + }) - return { schedule: toSchedule(data) } + const planSchedule = useMutation({ + ...tumblerplanMutation({ client }), + retry: false, + onMutate: () => { + setAlertMessage(undefined) + }, + onSettled: async () => { + await getScheduleQuery.refetch() + scrollToTop() + }, + onError: (error) => { + console.error('Plan schedule error:', error) + + const reason = getErrorReason(error, t('global.errors.reason_unknown')) + const message = t('scheduler.error_planning_schedule_failed', { reason }) + setAlertMessage(message) + toast.error(message) }, }) @@ -190,37 +118,22 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { reset: startScheduleMutationReset, mutateAsync: startScheduleMutationMutateAsync, } = useMutation({ - mutationFn: async (args: { - path: { walletname: WalletFileName } - body: { destinations: string[]; parameters?: typeof INSECURE_SCHEDULE_TUMBLER_OPTIONS } - }) => { - await tumblerplan({ - client, - path: args.path, - body: { - destinations: args.body.destinations, - parameters: args.body.parameters, - force: true, - }, - throwOnError: true, - }) - - return await tumblerstart({ - client, - path: args.path, - throwOnError: true, - }) - }, + ...tumblerstartMutation({ + client, + }), retry: false, onMutate: () => { setAlertMessage(undefined) }, - onSuccess: () => { - setShowScheduleConfirmDialog(false) + onSettled: async () => { + setShowScheduleConfirmDialog(undefined) + await getScheduleQuery.refetch() + scrollToTop() }, - onError: (error: unknown) => { + onError: (error) => { + console.error('Plan schedule error:', error) const reason = getErrorReason(error, t('global.errors.reason_unknown')) - const message = `${t('scheduler.error_starting_schedule_failed')} ${reason}` + const message = t('scheduler.error_starting_schedule_failed', { reason }) setAlertMessage(message) toast.error(message) }, @@ -232,29 +145,63 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { mutateAsync: stopScheduleMutationMutateAsync, reset: stopScheduleMutationReset, } = useMutation({ - ...tumblerstopMutation({ client }), + ...tumblerstopMutation({ + client, + path: { walletname: walletFileName }, + }), retry: false, onMutate: () => { setAlertMessage(undefined) }, + onSettled: async () => { + await getScheduleQuery.refetch() + scrollToTop() + }, onError: (error) => { const reason = getErrorReason(error, t('global.errors.reason_unknown')) - const message = `${t('scheduler.error_stopping_schedule_failed')} ${reason}` + const message = t('scheduler.error_stopping_schedule_failed', { reason }) setAlertMessage(message) toast.error(message) }, }) - const currentSchedule = isScheduleValue(getScheduleQuery.data?.schedule) ? getScheduleQuery.data.schedule : undefined + const { isPending: deleteScheduleMutationIsPending, mutateAsync: deleteScheduleMutationMutateAsync } = useMutation({ + ...tumblerplandeleteMutation({ + client, + path: { walletname: walletFileName }, + }), + retry: false, + onMutate: () => { + setAlertMessage(undefined) + scrollToTop() + }, + onSettled: async () => { + await getScheduleQuery.refetch() + }, + onError: (error) => { + const reason = getErrorReason(error, t('global.errors.reason_unknown')) + const message = t('scheduler.error_deleting_schedule_failed', { reason }) + setAlertMessage(message) + toast.error(message) + }, + }) - const schedulerRunning = jmSession?.coinjoin_in_process === true && currentSchedule !== undefined + const currentSchedule = useMemo(() => { + if (getScheduleQuery.error) return + if (getScheduleQuery.data === undefined) return + if (getScheduleQuery.data.stale === true) return + + return toSchedule(getScheduleQuery.data, walletInfo.jars) + }, [getScheduleQuery.error, getScheduleQuery.data, walletInfo.jars]) + + const schedulerRunning = takerInfo.scheduler.running const isWaitingSchedulerStart = startScheduleMutationIsPending || (startScheduleMutationIsSuccess && !schedulerRunning) - const waitingForTumblerStatus = jmSession?.coinjoin_in_process === true && getScheduleQuery.isPending + const waitingForTumblerStatus = takerInfo.running && getScheduleQuery.isPending const singleCoinJoinRunning = - jmSession?.coinjoin_in_process === true && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus - const makerRunning = jmSession?.maker_running === true - const collaborativeOperationRunning = makerRunning || jmSession?.coinjoin_in_process === true + takerInfo.running && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus + const makerRunning = makerInfo.running === true + const collaborativeOperationRunning = makerRunning || takerInfo.running const isWaitingSchedulerStop = stopScheduleMutationIsPending || (stopScheduleMutationIsSuccess && schedulerRunning) @@ -279,57 +226,24 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { const isOperationDisabled = feeConfigValidation.maxFeesConfigMissing || collaborativeOperationRunning || - jmSession?.rescanning || + rescanInfo.rescanning || !preconditionSummary.isFulfilled const isStartDisabled = isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop || - hasDestinationErrors || - !allDestinationAddressesPresent - - const onInsecureTestingToggleChange = (checked: boolean) => { - setUseInsecureTestingSettings(checked) - - if (checked) { - replace([{ address: getNewTestingDestinationAddress(walletInfo.addressSummary) }]) - void trigger('destinations') - return - } - - replace(buildSweepDestinationValues(DESTINATION_ADDRESS_COUNT_PROD)) - void trigger('destinations') - } + !getScheduleQuery.data || + startScheduleMutationIsPending || + planSchedule.isPending || + deleteScheduleMutationIsPending const startSchedule = async () => { - if (isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop) { - return - } + if (showScheduleConfirmDialog === undefined) return - await form.handleSubmit(async (values) => { - const body = { - destinations: getSweepDestinationAddresses(values), - ...(showInsecureScheduleTestingToggle && useInsecureTestingSettings - ? { parameters: INSECURE_SCHEDULE_TUMBLER_OPTIONS } - : {}), - } - - await startScheduleMutationMutateAsync({ - path: { walletname: walletFileName }, - body, - }) - })() - } - - const onOpenScheduleConfirm = async () => { - if (isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop) { - return - } - - await form.handleSubmit(() => { - setShowScheduleConfirmDialog(true) - })() + await startScheduleMutationMutateAsync({ + path: { walletname: walletFileName }, + }) } const stopSchedule = async () => { @@ -338,6 +252,12 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { }) } + const deleteSchedule = async () => { + await deleteScheduleMutationMutateAsync({ + path: { walletname: walletFileName }, + }) + } + if (!jmSession || feeConfigValidation.isLoading || walletInfo.isLoading) { return } @@ -351,10 +271,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { onOpenChange={setShowFeeConfigDialog} /> setShowScheduleConfirmDialog(undefined)} onConfirm={startSchedule} - disabled={isStartDisabled || isWaitingSchedulerStart} + disabled={isStartDisabled} isStarting={isWaitingSchedulerStart} />
@@ -388,25 +308,133 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => { {isWaitingSchedulerStart && ( - {t('scheduler.button_start')} + {t('scheduler.alert_scheduler_starting_title')} )} {isWaitingSchedulerStop && ( - {t('scheduler.button_stop')} + {t('scheduler.alert_scheduler_stopping_title')} )} - {schedulerRunning && currentSchedule && ( - + {!isWaitingSchedulerStart && !isWaitingSchedulerStop && !schedulerRunning && ( + )} - {!schedulerRunning && ( + {currentSchedule && ( <> - + {getScheduleQuery.isPending ? ( +
+ +
+ ) : ( + <> + {schedulerRunning && currentSchedule.summary.status.completed ? ( + + + {t('scheduler.progress_done_awaiting_stop')} + + ) : null} + + + {schedulerRunning && currentSchedule.summary.status.running ? ( + + ) : ( + <> + {currentSchedule.summary.status.pending || isWaitingSchedulerStart ? ( + + ) : null} + {currentSchedule.summary.status.pending && planSchedule.variables?.body ? ( + + ) : null} + + + + )} + + )} + + )} + + {!currentSchedule && !schedulerRunning && ( + <>
@@ -421,54 +449,51 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {

{t('scheduler.description_destination_addresses')}

- {showInsecureScheduleTestingToggle && ( -
- - -
- )} - - { + const parameters: Partial = { + ...formValuesToTumblerParameters(values), + ...(values.useInsecureTestingSettings ? { ...INSECURE_SCHEDULE_TUMBLER_OPTIONS } : {}), + } + + const body: TumblerPlanRequest = { + force: true, + destinations: values.destinations.map((it) => it.address), + parameters, + } + + await planSchedule.mutateAsync({ + path: { walletname: walletFileName }, + body, + }) + }} /> - -

{t('scheduler.description_fees')}

- - )} + + {isDeveloperMode && ( + + + + + +
+ getScheduleQuery.data: +
{JSON.stringify(getScheduleQuery.data, null, 2)}
+
+
+ planSchedule.data: +
{JSON.stringify(planSchedule.data, null, 2)}
+
+
+
+ )}
) diff --git a/src/components/sweep/SweepScheduleProgress.test.tsx b/src/components/sweep/SweepScheduleProgress.test.tsx index d822baaa..aec146f3 100644 --- a/src/components/sweep/SweepScheduleProgress.test.tsx +++ b/src/components/sweep/SweepScheduleProgress.test.tsx @@ -1,8 +1,7 @@ import { render, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { SweepScheduleProgress } from './SweepScheduleProgress' -import type { Schedule } from './scheduleUtils' +import { toSchedule } from './scheduleUtils' vi.mock('react-i18next', () => ({ Trans: ({ i18nKey, values }: { i18nKey: string; values?: Record }) => ( @@ -16,40 +15,285 @@ vi.mock('react-i18next', () => ({ }), })) +vi.mock('../ui/jam/Address', () => ({ + Address: ({ value }: { value?: string }) => {value}, +})) + describe('SweepScheduleProgress', () => { - it('renders active schedule progress and stops it', async () => { - const user = userEvent.setup() - const onStop = vi.fn().mockResolvedValue(undefined) - const schedule: Schedule = [ - [0, 0, 8, 'INTERNAL', 80, 16, 1], - [1, 0, 8, 'tx-destination', 5, 16, '8'.repeat(64)], - [2, 0, 8, 'final-destination', 0, 16, 0], - ] + it('renders active schedule progress and stops it', () => { + const schedule = toSchedule( + { + plan_id: 'any', + wallet_name: 'wallet.jmdat', + status: 'running', + destinations: ['final-destination'], + current_phase: 0, + phases: [ + { + kind: 'taker_coinjoin', + index: 0, + status: 'completed', + wait_seconds: 80 * 60, + started_at: '2026-07-19T10:35:52.775747+00:00', + finished_at: '2026-07-19T10:36:17.645587+00:00', + error: null, + mixdepth: 0, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'INTERNAL', + txid: '1'.repeat(64), + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + { + kind: 'taker_coinjoin', + index: 1, + status: 'running', + wait_seconds: 5 * 60, + started_at: '2026-07-19T10:39:11.684475+00:00', + finished_at: null, + error: null, + mixdepth: 1, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'INTERNAL', + txid: '2'.repeat(64), + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + { + kind: 'taker_coinjoin', + index: 2, + status: 'pending', + wait_seconds: 0, + started_at: null, + finished_at: null, + error: null, + mixdepth: 2, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'final-destination', + txid: null, + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + ], + created_at: '2009-01-03T10:35:51.466560+00:00', + updated_at: '2009-01-03T10:35:52.775780+00:00', + error: null, + stale: false, + }, + [], + ) - render() + render() - expect(screen.getByText(/scheduler.progress_tldr_hours/u)).toBeInTheDocument() - expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation/u)).toBeInTheDocument() - expect(screen.getByText(/scheduler.progress_entry_state_confirmed/u)).toBeInTheDocument() - expect(screen.getByText(/scheduler.progress_entry_state_waiting_confirmation/u)).toBeInTheDocument() - expect(screen.getByText(/scheduler.progress_entry_wait_final/u)).toBeInTheDocument() - - await user.click(screen.getByRole('button', { name: 'scheduler.button_stop' })) - expect(onStop).toHaveBeenCalledTimes(1) + expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"3","hours":"3"}')).toBeInTheDocument() + expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation_title/u)).toBeInTheDocument() + expect(screen.getByText(/scheduler.progress_current_state_waiting_confirmation_description/u)).toBeInTheDocument() }) - it('shows done and stopping states', () => { - const schedule: Schedule = [ - [0, 0, 8, 'INTERNAL', 0.25, 16, 1], - [1, 0, 8, 'final-destination', 0, 16, 1], - ] + it('shows completed schedule', () => { + const schedule = toSchedule( + { + plan_id: 'any', + wallet_name: 'wallet.jmdat', + status: 'completed', + destinations: ['final-destination'], + current_phase: 1, + phases: [ + { + kind: 'taker_coinjoin', + index: 0, + status: 'completed', + wait_seconds: 60, + started_at: '2026-07-19T10:35:52.775747+00:00', + finished_at: '2026-07-19T10:36:17.645587+00:00', + error: null, + mixdepth: 0, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'INTERNAL', + txid: '1'.repeat(64), + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + { + kind: 'taker_coinjoin', + index: 1, + status: 'completed', + wait_seconds: 0, + started_at: '2026-07-19T11:35:52.775747+00:00', + finished_at: '2026-07-19T11:36:17.645587+00:00', + error: null, + mixdepth: 2, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'final-destination', + txid: null, + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + ], + created_at: '2009-01-03T10:35:51.466560+00:00', + updated_at: '2009-01-03T10:35:52.775780+00:00', + error: null, + stale: false, + }, + [], + ) - const { rerender } = render() + render() - expect(screen.getByText(/scheduler.progress_tldr_seconds/u)).toBeInTheDocument() - expect(screen.getByText('scheduler.progress_done')).toBeInTheDocument() + expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument() + expect(screen.getByText('Scheduled sweep finished successfully.')).toBeInTheDocument() + }) - rerender() - expect(screen.getByRole('button', { name: /scheduler\.button_stop/u })).toBeDisabled() + it('shows failed schedule', () => { + const schedule = toSchedule( + { + plan_id: 'any', + wallet_name: 'wallet.jmdat', + status: 'failed', + destinations: ['final-destination'], + current_phase: 1, + phases: [ + { + kind: 'taker_coinjoin', + index: 0, + status: 'completed', + wait_seconds: 30, + started_at: '2026-07-19T10:35:52.775747+00:00', + finished_at: '2026-07-19T10:36:17.645587+00:00', + error: null, + mixdepth: 0, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'INTERNAL', + txid: '1'.repeat(64), + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + { + kind: 'taker_coinjoin', + index: 1, + status: 'failed', + wait_seconds: 0, + started_at: '2026-07-19T11:35:52.775747+00:00', + finished_at: '2026-07-19T11:36:17.645587+00:00', + error: null, + mixdepth: 2, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'final-destination', + txid: null, + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + ], + created_at: '2009-01-03T10:35:51.466560+00:00', + updated_at: '2009-01-03T10:35:52.775780+00:00', + error: null, + stale: false, + }, + [], + ) + + render() + + expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument() + expect(screen.getByText('Scheduled sweep failed.')).toBeInTheDocument() + }) + + it('shows cancelled schedule', () => { + const schedule = toSchedule( + { + plan_id: 'any', + wallet_name: 'wallet.jmdat', + status: 'cancelled', + destinations: ['final-destination'], + current_phase: 1, + phases: [ + { + kind: 'taker_coinjoin', + index: 0, + status: 'completed', + wait_seconds: 30, + started_at: '2026-07-19T10:35:52.775747+00:00', + finished_at: '2026-07-19T10:36:17.645587+00:00', + error: null, + mixdepth: 0, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'INTERNAL', + txid: '1'.repeat(64), + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + { + kind: 'taker_coinjoin', + index: 1, + status: 'cancelled', + wait_seconds: 0, + started_at: '2026-07-19T11:35:52.775747+00:00', + finished_at: '2026-07-19T11:36:17.645587+00:00', + error: null, + mixdepth: 2, + amount: 0, + amount_fraction: null, + counterparty_count: 8, + destination: 'final-destination', + txid: null, + duration_seconds: null, + target_cj_count: null, + idle_timeout_seconds: null, + cj_served: null, + attempt_count: 0, + }, + ], + created_at: '2009-01-03T10:35:51.466560+00:00', + updated_at: '2009-01-03T10:35:52.775780+00:00', + error: null, + stale: false, + }, + [], + ) + + render() + + expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument() + expect(screen.getByText('Scheduled sweep cancelled.')).toBeInTheDocument() }) }) diff --git a/src/components/sweep/SweepScheduleProgress.tsx b/src/components/sweep/SweepScheduleProgress.tsx index 05b93fd0..ecfd5c4c 100644 --- a/src/components/sweep/SweepScheduleProgress.tsx +++ b/src/components/sweep/SweepScheduleProgress.tsx @@ -1,30 +1,40 @@ +import { useState } from 'react' +import { + AlertTriangleIcon, + CalendarCheck2Icon, + CalendarClockIcon, + CheckCircle2Icon, + CheckIcon, + ClockAlertIcon, + CopyIcon, + HourglassIcon, +} from 'lucide-react' import { Trans, useTranslation } from 'react-i18next' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' -import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' -import { cn } from '@/lib/utils' +import { cn, shortenStringMiddle } from '@/lib/utils' +import { DevBadge } from '../dev/DevBadge' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' +import { buttonVariants } from '../ui/button-variants' +import { Item, ItemContent, ItemGroup, ItemTitle } from '../ui/item' +import { Address } from '../ui/jam/Address' +import { CopyButton } from '../ui/jam/CopyButton' +import { Label } from '../ui/label' import { Spinner } from '../ui/spinner' -import type { Schedule, ScheduleEntryState } from './scheduleUtils' -import { toScheduleProgressSummary } from './scheduleUtils' - -interface SweepScheduleProgressProps { - schedule: Schedule - isStopping: boolean - onStop: () => Promise -} - -const SweepProgressBar = ({ schedule }: { schedule: Schedule }) => { - const progress = toScheduleProgressSummary(schedule) +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs' +import { ScheduleEntryItem } from './ScheduleEntryItem' +import type { Schedule, ScheduleProgressSummary } from './scheduleUtils' +const SweepProgressBar = ({ progress }: { progress: ScheduleProgressSummary }) => { return (
{progress.steps.map((step, index) => (
@@ -33,39 +43,27 @@ const SweepProgressBar = ({ schedule }: { schedule: Schedule }) => { ) } -export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepScheduleProgressProps) => { +const alertTitleComponents = { + '1': , + '3': , +} +const alertDescriptionWaitingForConfirmationsComponents = { + '1': , +} + +type Tab = 'completed' | 'pending' | 'all' + +interface SweepScheduleProgressProps { + schedule: Schedule + debug?: boolean +} + +export const SweepScheduleProgress = ({ schedule, debug }: SweepScheduleProgressProps) => { const { t } = useTranslation() - const progress = toScheduleProgressSummary(schedule) - const totalHours = Math.ceil(progress.totalWaitSeconds / 60 / 60) - const totalSeconds = Math.ceil(progress.totalWaitSeconds) - const highlightedComponents = { - '1': , - '3': , - } + const totalHours = Math.ceil(schedule.summary.estimatedTotalDurationInSeconds / 60 / 60) + const totalSeconds = Math.ceil(schedule.summary.estimatedTotalDurationInSeconds) - const formatWaitTime = (seconds: number): string => { - const roundedSeconds = Math.max(0, Math.ceil(seconds)) - const minutes = Math.floor(roundedSeconds / 60) - const remainingSeconds = roundedSeconds % 60 - - if (minutes === 0) { - return t('scheduler.progress_wait_seconds', { seconds: remainingSeconds }) - } - if (remainingSeconds === 0) { - return t('scheduler.progress_wait_minutes', { minutes }) - } - return t('scheduler.progress_wait_minutes_seconds', { minutes, seconds: remainingSeconds }) - } - - const toScheduleEntryStateText = (state: ScheduleEntryState, txid?: string): string => { - if (state === 'confirmed') { - return t('scheduler.progress_entry_state_confirmed') - } - if (state === 'broadcasted') { - return t('scheduler.progress_entry_state_waiting_confirmation', { txid: txid ?? '-' }) - } - return t('scheduler.progress_entry_state_pending') - } + const [activeTab, setActiveTab] = useState(() => 'all') return ( @@ -75,110 +73,310 @@ export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepSch

) : (

)}

{t('scheduler.progress_description')}

- + - {progress.isDone ? ( + {schedule.summary.status.completed ? ( - - {t('scheduler.progress_done')} - - ) : ( - - - - {/* Keep a stable fallback state so brief polling gaps never leave this header empty. */} - {progress.currentState?.type === 'waiting_before_next' ? ( - - ) : progress.currentState?.type === 'waiting_for_confirmation' ? ( - - ) : progress.currentState?.type === 'transaction_confirmed' ? ( - - ) : ( - - )} - + + {/* TODO: i18n */ 'Scheduled sweep finished successfully.'} - )} + ) : null} -
-
{t('scheduler.progress_schedule_info_title')}
-
- {progress.entries.map((entry) => ( -
-
{t('scheduler.progress_entry_label', { index: entry.index + 1 })}
-
{toScheduleEntryStateText(entry.state, entry.txid)}
-
- {entry.isLast - ? t('scheduler.progress_entry_wait_final') - : t('scheduler.progress_entry_wait_before_next', { - wait: formatWaitTime(entry.waitBeforeNextSeconds), - })} + {schedule.summary.status.failed ? ( + + + {/* TODO: i18n */ 'Scheduled sweep failed.'} + + + ) : null} + + {schedule.summary.status.cancelled ? ( + + + {/* TODO: i18n */ 'Scheduled sweep cancelled.'} + + + ) : null} + + {schedule.summary.status.pending ? ( + + + {/* TODO: i18n */ 'Scheduled sweep pending.'} + + + ) : null} + + {schedule.summary.status.running ? ( + <> + {/* Keep a stable fallback state so brief polling gaps never leave this header empty. */} + {schedule.summary.derivedStatus.value === 'waiting_for_confirmation' ? ( + + + + + + + + + + ) : schedule.summary.derivedStatus.value === 'waiting_before_next' ? ( + + + + + + + + ) : ( + + + + + + + + )} + + ) : null} + + {schedule.summary.startedAt ? ( + + +
+ +
+ + + {schedule.summary.startedAt.toLocaleString()} +
- ))} -
-
+
+ +
+ + {schedule.summary.finishedAt === undefined ? ( + '-' + ) : ( + + {schedule.summary.finishedAt.toLocaleString()} + + )} +
+
+ + + ) : null} - + {schedule.active !== undefined ? ( + + + + { + /* TODO: i18n */ t('scheduler.section_active_action_title', { + defaultValue: 'Active action', + }) + } + + + + + + + + + ) : null} + + {schedule.summary.externalDestinations.length === 0 ? null : ( + + + + { + /* TODO: i18n */ t('scheduler.section_destinations_title', { + defaultValue: 'Destinations', + }) + } + + +

{t('scheduler.description_destination_addresses')}

+ {schedule.summary.externalDestinations.map((value, index) => { + return ( + + + + +
+ + {value.transactionId ? ( +
+ + +
+ + {value.transactionId} + + } + successText={} + className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')} + /> +
+
+ ) : null} + + + ) + })} + + + + )} + + + + + { + /* TODO: i18n */ t('scheduler.section_details_title', { + defaultValue: 'Schedule details', + }) + } + + + setActiveTab(value as Tab)} + className="flex flex-col gap-4" + > + + + {/* TODO: i18n */}Pending ({schedule.pending.length.toLocaleString()}) + + + {/* TODO: i18n */}Completed ({schedule.completed.length.toLocaleString()}) + + + {/* TODO: i18n */}All ({schedule.entries.length.toLocaleString()}) + + + + {activeTab === 'completed' ? ( + schedule.completed.length === 0 ? ( + <> +
+ No completed actions yet.{/* TODO: i18n */} +
+ + ) : ( + + {schedule.completed.map((entry, index) => { + return + })} + + ) + ) : null} + {activeTab === 'pending' ? ( + schedule.pending.length === 0 ? ( + <> +
+ No pending actions.{/* TODO: i18n */} +
+ + ) : ( + + {schedule.pending.map((entry, index) => { + return + })} + + ) + ) : null} + {activeTab === 'all' ? ( + + {schedule.entries.map((entry, index) => { + return + })} + + ) : null} +
+
+
+ + {debug && ( + + + + + +
+ Debug +
+
+ +
+ schedule: +
{JSON.stringify(schedule, null, 2)}
+
+
+
+
+
+
+ )} ) diff --git a/src/components/sweep/SweepStartConfirmDialog.test.tsx b/src/components/sweep/SweepStartConfirmDialog.test.tsx index 30872fc6..9a314b08 100644 --- a/src/components/sweep/SweepStartConfirmDialog.test.tsx +++ b/src/components/sweep/SweepStartConfirmDialog.test.tsx @@ -43,8 +43,8 @@ describe('SweepStartConfirmDialog', () => { />, ) - expect(screen.getByText('scheduler.confirm_modal.title')).toBeInTheDocument() - expect(screen.getByText('scheduler.confirm_modal.body')).toBeInTheDocument() + expect(screen.getByText('scheduler.confirm_start_modal.title')).toBeInTheDocument() + expect(screen.getByText('scheduler.confirm_start_modal.text_body')).toBeInTheDocument() expect(screen.getByText('modal.confirm_button_reject')).toBeInTheDocument() expect(screen.getByText('modal.confirm_button_accept')).toBeInTheDocument() }) diff --git a/src/components/sweep/SweepStartConfirmDialog.tsx b/src/components/sweep/SweepStartConfirmDialog.tsx index 5b092072..e0e7951c 100644 --- a/src/components/sweep/SweepStartConfirmDialog.tsx +++ b/src/components/sweep/SweepStartConfirmDialog.tsx @@ -24,9 +24,9 @@ export const SweepStartConfirmDialog = ({ - {t('scheduler.confirm_modal.title')} + {t('scheduler.confirm_start_modal.title')} -

{t('scheduler.confirm_modal.body')}

+

{t('scheduler.confirm_start_modal.text_body')}

- - - Wallet actions - - Rename wallet +/** + * The default form of the dropdown menu. + */ +export const Default: Story = {} + +/** + * A dropdown menu with shortcuts. + */ +export const WithShortcuts: Story = { + render: (args) => ( + + Open + + Controls - Export backup - ⌘B + Back + ⌘[ + + + Forward + ⌘] - Delete wallet ), } -export const WithSelectionItems: Story = { - render: () => ( - - - - - - Show balances - Show frozen coins +/** + * A dropdown menu with submenus. + */ +export const WithSubmenus: Story = { + render: (args) => ( + + Open + + + + Search + - - BTC - Sats + + + + New Team + ⌘+T + + + + + Invite users + + + + + + Email + + + + + More... + + + + + + + + ), +} + +/** + * A dropdown menu with radio items. + */ +export const WithRadioItems: Story = { + render: (args) => ( + + Open + + Status + + Info + Warning + Error ), } -export const WithDisabledItem: Story = { - render: () => ( - - - - - - Wallet actions - - Rename wallet - Export backup - Delete wallet +/** + * A dropdown menu with checkboxes. + */ +export const WithCheckboxes: Story = { + render: (args) => ( + + Open + + + Autosave + ⌘S + + Show Comments ), } + +export const ShouldOpenClose: Story = { + name: 'when clicking an item, should close the dropdown menu', + tags: ['!dev', '!autodocs'], + play: async ({ canvasElement, step }) => { + const body = within(canvasElement.ownerDocument.body) + + await step('Open the dropdown menu', async () => { + await userEvent.click(await body.findByRole('button', { name: /open/i })) + expect(await body.findByRole('menu')).toBeInTheDocument() + }) + const items = await body.findAllByRole('menuitem') + expect(items).toHaveLength(5) + + await step('Click the first menu item', async () => { + await userEvent.click(items[0], { delay: 100 }) + }) + }, +} diff --git a/src/stories/Field.stories.tsx b/src/stories/Field.stories.tsx index cc793460..a8f5ebc9 100644 --- a/src/stories/Field.stories.tsx +++ b/src/stories/Field.stories.tsx @@ -1,3 +1,4 @@ +import React from 'react' import type { Meta, StoryObj } from '@storybook/react-vite' import { Checkbox } from '@/components/ui/checkbox' import { @@ -13,67 +14,334 @@ import { FieldTitle, } from '@/components/ui/field' import { Input } from '@/components/ui/input' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Slider } from '@/components/ui/slider' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' const meta: Meta = { title: 'Core/Field', component: Field, tags: ['autodocs'], -} + argTypes: { + orientation: { + control: 'select', + options: ['vertical', 'horizontal', 'responsive'], + }, + }, + parameters: { + layout: 'centered', + }, + args: { + orientation: 'vertical', + }, + decorators: (Story) => ( +
+ +
+ ), +} satisfies Meta + export default meta type Story = StoryObj -export const Default: Story = { - render: () => ( - - - Wallet name - - This name is only used locally in Jam. - - - ), -} - -export const Horizontal: Story = { - render: () => ( - - - - Remember wallet - Keep this wallet available in the wallet list. - - - ), -} - -export const WithError: Story = { - render: () => ( - - - Wallet password - - Password is required. - - - ), -} - -export const Fieldset: Story = { - render: () => ( -
- Fee settings +/** + * Field with Input component for text input. + */ +export const WithInput: Story = { + render: (args) => ( +
- - Minimum fee - - - or - - Use default fee - Let Jam choose the default fee for this wallet. + + Username + + Choose a unique username for your account.
), } + +export const WithError: Story = { + render: () => ( +
+ + + Wallet password + + Password is required. + + +
+ ), +} + +export const WithErrors: Story = { + render: () => ( +
+ + + Wallet password + + + + + + + + +
+ ), +} + +/** + * Field with textarea for longer text input. + */ +export const WithTextarea: Story = { + render: (args) => ( +
+ + + Feedback +