feat: plan scheduled sweep (#1339)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run

* refactor: add reason to scheduler error messages

* fix: dismiss schedule confirm dialog on error

* chore(i18n): distinct scheduler starting/stopping messages

* chore(sweep): remove redundant manual destination validation

* chore(sweep): initial sweep options

* chore(ui): add slider component

* chore(sweep): add rounding chance option

* feat(sweep): ability to add/remove destinations

* chore(sweep): use plan tumbler mutation

* refactor(sweep): use tumblerstartMutation

* refactor(sweep): externalize SweepForm

* feat(sweep): ability to set min/max number of collaborators

* chore(sweep): increase time_lambda_seconds in insecure test options

* chore(sweep): fix progress step of quick sweep

* chore(sweep): start refactor schedule entry

* chore(sweep): preserve raw data in ScheduleEntry

* feat(sweep): show destination addresses

* chore(sweep): scroll to top after start/stop

* chore(ui): add Item component

* fix: only abort single taker runs on send page

* chore(sweep): schedule entries with Item component

* chore(sweep): show wait time before next action

* chore(sweep): schedule entries in accordion

* chore(sweep): remove scheduler.progress_current_state_wait_before_next message

* chore(sweep): tab for completed entries

* refactor(sweep): align Schedule object structure

* chore(sweep): display error message on failed actions

* chore(sweep): display completed scheduled sweep

* chore(sweep): move stop button from child component to page

* feat(sweep): ability to review scheduled sweep plan

* chore(sweep): increase schedule polling from 3s to 10s

* chore(sweep): hourglass icon for waiting alerts

* chore(sweep): adapt buttons, labels and alert texts

* fix(sweep): default min confirmations between phases

* chore(sweep): improve time estimate on sweeps

* chore(sweep): show pending tasks in details

* feat(sweep): ability to configure mintxcount

* chore(storybook): add slider, sidebar and item stories

* test(sweep): verify add/remove sweep destination input

* test: load decorators from story meta config

* chore(sweep): add makeSessionIdleTimeoutSeconds to form values

* chore(sweep): reload schedule if not stale or terminated

* chore(sweep): more details in sections

* chore(sweep): reorder entry details

* chore(sweep): show transaction info in destination section

* chore(sweep): estimate duration with fixed runtime info of maker phases

* chore(regtest): setup dummy wallet fidelity bonds

* fix(sweep): maker idle timeout less than runtime duration

* chore(sweep): use taker_utxo_age as min confs in sweep preconditions

* chore(build): add task test:coverage

* chore(sweep): max number of destination addresses

* chore(sweep): maxNumberOfDestinations
This commit is contained in:
Thebora Kompanioni 2026-07-23 13:06:40 +02:00 committed by GitHub
parent f9523729ae
commit 64ecd7ad77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 4688 additions and 976 deletions

6
.env
View file

@ -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

View file

@ -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

5
.env.test Normal file
View file

@ -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

View file

@ -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

View file

@ -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'

View file

@ -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() {

1
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -220,13 +220,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<div className="mx-auto max-w-4xl space-y-3 p-4">
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
<div className="flex justify-end">
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
<FileTextIcon />
{t('earn.button_show_report')}
</Button>
</div>
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
@ -234,7 +227,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{jmSession.coinjoin_in_process === true && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
</Alert>
)}
@ -263,6 +256,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</Alert>
)}
<div className="flex justify-end">
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
<FileTextIcon />
{t('earn.button_show_report')}
</Button>
</div>
{jmSession.offer_list && jmSession.offer_list.length > 0 && (
<OfferCard
className="motion-safe:animate-in blur-in"

View file

@ -20,9 +20,8 @@ interface UseJmwalletdStdoutLogParameters {
}
export function useJmwalletdStdoutLog({ enabled = true }: UseJmwalletdStdoutLogParameters = {}) {
const authState = useStore(authStore, (state) => state.state)
const { t } = useTranslation()
const token = authState?.auth?.token
const token = useStore(authStore, (state) => state.state?.auth?.token)
const {
refetch: logQueryRefetch,

View file

@ -236,9 +236,10 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
<Accordion type="single" collapsible>
<AccordionItem value="options">
<AccordionTrigger>{t('receive.button_settings')}</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4">
<AccordionContent
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
>
<ReceiveForm
className={'mx-1' /* add x-spacing for input component focus state*/}
defaultValues={receiveFormDefaultValues}
jars={jars}
disabled={getAddressMutation.isPending}

View file

@ -11,7 +11,7 @@ import { toast } from 'sonner'
import QrScannerDialog from '@/components/ui/QrScannerDialog'
import { isDevMode } from '@/constants/debugFeatures'
import { MAX_NUM_COLLABORATORS } from '@/constants/jam'
import { JM_MINIMUM_MAKERS_DEFAULT } from '@/constants/jm'
import { JM_MINIMUM_MAKERS_DEFAULT, JM_TAKER_UTXO_AGE } from '@/constants/jm'
import { useDetectNetwork, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { BalanceSummary } from '@/lib/balanceSummary'
@ -176,7 +176,7 @@ export function SendForm({
[formState.errors, formState.isValid, formState.isSubmitting],
)
const collapsibleFormElementsValid = useMemo(
() => 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<HTMLInputElement>) => {
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({
</AccordionTrigger>
<AccordionContent
className={cn('flex flex-col gap-6', 'mx-1' /* add x-spacing for input component focus state*/)}
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
>
<div className="flex items-center gap-2">
<Switch

View file

@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({
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(<SendPage walletFileName="wallet.jmdat" />)
@ -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(<SendPage walletFileName="wallet.jmdat" />)
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({})

View file

@ -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 && (
<Alert variant="warning">
<HourglassIcon />
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
{currentPaymentAttempt && (
@ -516,15 +520,17 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
)}
</pre>
)}
<div>
<Button
variant="outline"
onClick={() => setShowAbortCoinjoinDialog(true)}
disabled={isWaitingCoinjoinStop}
>
{t('global.abort')}
</Button>
</div>
{takerRunning && !schedulerRunning ? (
<div>
<Button
variant="outline"
onClick={() => setShowAbortCoinjoinDialog(true)}
disabled={isWaitingCoinjoinStop}
>
{t('global.abort')}
</Button>
</div>
) : null}
</AlertDescription>
</Alert>
)}

View file

@ -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 (
<Badge
variant={variant}
className={cn({
'ring-ring/50 ring-1 motion-safe:animate-pulse': active,
})}
>
{/* TODO: i18n */ status.value}
</Badge>
)
}
export const ScheduleEntryItem = ({ value, active }: { value: ScheduleEntry; active: boolean }) => {
const { t } = useTranslation()
return (
<Item
variant="outline"
className={cn({
'ring-brand-success ring-1': value.status.completed,
'ring-destructive ring-1': value.status.failed,
'ring-muted-foreground ring-1': value.status.skipped,
'ring-ring/50 ring-2': active,
'bg-muted text-muted-foreground': value.derivedStatus.terminated,
})}
>
<ItemHeader>
<div>
<ItemTitle
className={cn({
'text-destructive': value.status.failed,
'text-brand-warning': value.status.cancelled,
'text-muted-foreground': value.status.skipped,
})}
>
{t('scheduler.progress_entry_label', { index: (value.index + 1).toLocaleString() })}
</ItemTitle>
<ItemDescription>
{
/* TODO: i18n */
value.kind === 'taker_coinjoin' ? (
<Trans
i18nKey="A collaborative transaction as <1>taker</1> with {{numberOfRequestedCounterparties}} counterparties"
values={{ numberOfRequestedCounterparties: value.numberOfRequestedCounterparties?.toLocaleString() }}
components={{
'1': <span className="font-semibold" />,
}}
/>
) : value.kind === 'maker_session' ? (
<Trans
i18nKey="A collaborative transaction as <1>maker</1>"
components={{
'1': <span className="font-semibold" />,
}}
/>
) : null
}
</ItemDescription>
</div>
<div className="flex flex-row-reverse flex-wrap items-center gap-2">
<EntryStatusBadge active={active} status={value.status} />
{value.kind === 'maker_session' ? <Badge variant="info">{/* TODO: i18n */ 'Earn'}</Badge> : null}
{value.kind === 'taker_coinjoin' ? <Badge variant="default">{/* TODO: i18n */ 'Send'}</Badge> : null}
{value.isSweep ? <Badge variant="outline">{/* TODO: i18n */ 'Sweep'}</Badge> : null}
</div>
</ItemHeader>
<ItemContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{value.__raw?.error ? (
<div className="text-destructive col-span-full flex min-w-0 items-start gap-4">
<AlertTriangleIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Error'}</Label>
{value.__raw?.error ?? t('global.errors.reason_unknown')}
</div>
</div>
) : null}
{value.startedAt ? (
<div className="flex min-w-0 items-start gap-4">
<CalendarClockIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Started At'}</Label>
<span title={value.startedAt.toISOString()}>{value.startedAt.toLocaleString()}</span>
</div>
</div>
) : null}
{value.startedAt || value.finishedAt ? (
<div className="flex min-w-0 items-start gap-4">
<CalendarCheck2Icon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Finished At'}</Label>
{value.finishedAt === undefined ? (
'-'
) : (
<span title={value.finishedAt.toISOString()}>{value.finishedAt.toLocaleString()}</span>
)}
</div>
</div>
) : null}
{value.jar ? (
<div className="flex min-w-0 items-start gap-4">
<MilkIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Source Jar'}</Label>
<Badge variant={jarBadgeVariant(value.jar.jarIndex)}>
{value.jar.name} <span className="text-xs">#{value.jar.jarIndex.toLocaleString()}</span>
</Badge>
</div>
</div>
) : null}
{value.numberOfRequestedCounterparties ? (
<div className="flex min-w-0 items-start gap-4">
<UsersIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Request collaborators'}</Label>
{value.numberOfRequestedCounterparties.toLocaleString()}
</div>
</div>
) : null}
{/*value.__raw.durationSeconds ? (
<div className="flex min-w-0 items-start gap-4">
<ClockFadingIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{'Duration'}</Label>
<span>{value.__raw.durationSeconds}</span>
</div>
</div>
) : null*/}
{value.idleTimeoutSeconds ? (
<div className="flex min-w-0 items-start gap-4">
<ClockFadingIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/* TODO: i18n*/ 'Idle Timeout'}</Label>
{formatDuration(value.idleTimeoutSeconds, t)}
</div>
</div>
) : null}
{value.waitTimeInSeconds ? (
<div className="flex min-w-0 items-start gap-4">
<TimerResetIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{t('scheduler.progress_entry_wait_before_next_title')}</Label>
{value.waitTimeInSeconds <= 0 ? '-' : formatDuration(value.waitTimeInSeconds, t)}
</div>
</div>
) : null}
{value.externalDestinationAddress ? (
<div className="col-span-full flex min-w-0 items-start gap-4">
<ExternalLinkIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{/*TODO: i18n */ 'External destination'}</Label>
<Address value={value.externalDestinationAddress} />
</div>
</div>
) : null}
{value.transactionId ? (
<div className="col-span-full flex min-w-0 items-start gap-4">
<FingerprintIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Transaction ID'}</Label>
<div className="flex items-center gap-2">
<span className="text-md block font-mono break-all select-all">{value.transactionId}</span>
<CopyButton
value={value.transactionId}
text={
<>
<CopyIcon />
{t('global.button_copy_text')}
</>
}
successText={
<>
<CopyCheckIcon />
{t('global.button_copy_text_confirmed')}
</>
}
className={cn(buttonVariants({ variant: 'outline', size: 'xs' }), 'shrink-0')}
/>
</div>
</div>
</div>
) : null}
</ItemContent>
</Item>
)
}

View file

@ -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(<SweepDestinationInputs form={defaultFormMock} fields={fields} disabled={false} />)
it('renders input fields correctly', async () => {
render(
<SweepDestinationInputs
minNumberOfFields={fields.length}
maxNumberOfFields={fields.length}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
/>,
)
// 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(
<SweepDestinationInputs
minNumberOfFields={fields.length}
maxNumberOfFields={fields.length}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
/>,
)
// 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(<SweepDestinationInputs form={defaultFormMock} fields={fields} disabled={true} />)
render(
<SweepDestinationInputs
minNumberOfFields={fields.length}
maxNumberOfFields={fields.length}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
disabled={true}
/>,
)
const inputs = screen.getAllByRole('textbox')
expect(inputs[0]).toBeDisabled()
expect(inputs[1]).toBeDisabled()
})
it('handles bip21 format from paste events', async () => {
render(
<SweepDestinationInputs
minNumberOfFields={fields.length}
maxNumberOfFields={fields.length}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
/>,
)
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(
<SweepDestinationInputs
minNumberOfFields={1}
maxNumberOfFields={maxNumberOfFields}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
onClickAppend={() => {
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(
<SweepDestinationInputs
minNumberOfFields={1}
maxNumberOfFields={3}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
onClickRemove={() => 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(
<SweepDestinationInputs
minNumberOfFields={1}
maxNumberOfFields={3}
formState={defaultFormMock.formState}
setValue={defaultFormMock.setValue}
register={defaultFormMock.register}
fields={fields}
/>,
)
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(<SweepDestinationInputs form={errorFormMock} fields={fields} disabled={false} />)
render(
<SweepDestinationInputs
minNumberOfFields={fields.length}
maxNumberOfFields={fields.length}
formState={errorFormMock.formState}
setValue={errorFormMock.setValue}
register={errorFormMock.register}
fields={fields}
/>,
)
expect(screen.getByText('Invalid address 1')).toBeInTheDocument()

View file

@ -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<SweepFormValues, unknown, SweepFormValues>
interface SweepDestinationInputsProps {
form: UseFormReturn<SweepFormValues, unknown, SweepFormValues>
fields: Array<FieldArrayWithId<SweepFormValues, 'destinations', 'id'>>
disabled: boolean
minNumberOfFields: number
maxNumberOfFields: number
register: ParentForm['register']
setValue: ParentForm['setValue']
formState: ParentForm['formState']
fields: UseFieldArrayReturn<SweepFormValues, 'destinations', 'id'>['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<number>()
@ -36,9 +49,7 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
const handleAddressPaste = useCallback(
(event: React.ClipboardEvent<HTMLInputElement>, 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
<ButtonGroup className="w-full">
<Input
id={`sweep-destination-${index}`}
{...register(`destinations.${index}.address`)}
{...register(`destinations.${index}.address` as const)}
className="font-mono"
placeholder={t('scheduler.placeholder_destination_input')}
disabled={disabled}
@ -83,6 +94,12 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
spellCheck={false}
onPaste={(event) => handleAddressPaste(event, index)}
/>
{index >= minNumberOfFields && onClickRemove ? (
<Button type="button" variant="outline" size="lg" onClick={() => onClickRemove(index)}>
<XIcon />
<span className="sr-only">{t('global.clear')}</span>
</Button>
) : null}
<Button
id={`show-qr-scanner-trigger-${index}`}
type="button"
@ -101,6 +118,21 @@ export const SweepDestinationInputs = ({ form, fields, disabled }: SweepDestinat
)
})}
</div>
{onClickAppend ? (
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={onClickAppend}
disabled={disabled || fields.length >= maxNumberOfFields}
>
<PlusCircleIcon />
{/* TODO: i18n */}
Add additional destination
</Button>
</div>
) : null}
</>
)
}

View file

@ -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<SweepFormValues>
initialValues?: Partial<SweepFormValues>
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 (
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
{showInsecureScheduleTestingToggle && (
<div className="flex items-center gap-2">
<Switch
id="switch-use-insecure-schedule-testing"
checked={formWatch.useInsecureTestingSettings}
onCheckedChange={onInsecureTestingToggleChange}
disabled={disabled}
/>
<Label htmlFor="switch-use-insecure-schedule-testing" className="flex flex-col items-start gap-0">
<div className="flex items-center gap-2 font-medium">
Use insecure testing settings
<DevBadge />
</div>
<div className="text-muted-foreground text-sm">
This is completely insecure but makes testing the schedule much faster.
</div>
</Label>
</div>
)}
<SweepDestinationInputs
minNumberOfFields={minNumberOfDestinations}
maxNumberOfFields={maxNumberOfDestinations}
register={register}
setValue={setValue}
formState={formState}
fields={destinationsFieldArray.fields}
disabled={disabled}
onClickAppend={() => {
destinationsFieldArray.append(buildSweepDestinationValues(1), { shouldFocus: false })
}}
onClickRemove={(index: number) => {
destinationsFieldArray.remove(index)
}}
/>
<Accordion type="single" collapsible>
<AccordionItem value="options">
<AccordionTrigger
className={cn({
'text-destructive': !collapsibleFormElementsValid,
})}
>
<div className="flex items-center gap-2">
{!collapsibleFormElementsValid ? <AlertTriangleIcon /> : null}
{t('scheduler.scheduler_options')}
</div>
</AccordionTrigger>
<AccordionContent
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
>
<div className="flex items-center gap-2">
<Switch
id="switch-include-maker-sessions"
checked={formWatch.includeMakerSessions}
onCheckedChange={(checked: boolean) => setValue('includeMakerSessions', checked)}
disabled={disabled}
/>
<Label htmlFor="switch-include-maker-sessions" className="flex flex-col items-start gap-0">
{/* TODO: i18n */}
<div className="flex items-center gap-2 font-medium">Include maker sessions</div>
<div className="text-muted-foreground text-sm">
Occasionally switch from taker to maker, which aids privacy.
</div>
</Label>
</div>
<div className="flex flex-col justify-center gap-2">
<div className="space-y-2">
<Field
data-invalid={
formState.errors.minNumberOfCollaborators !== undefined ||
formState.errors.maxNumberOfCollaborators !== undefined
}
className="gap-4"
>
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start gap-0">
<FieldLabel htmlFor="slider-min-max-maker">{/* TODO: i18n */}Number of collaborators</FieldLabel>
<FieldDescription className="text-xs">
{/* TODO: i18n */}More collaborators are better for privacy, but also increase transaction fees.
</FieldDescription>
</div>
<span className="text-foreground">
{formWatch.minNumberOfCollaborators} - {formWatch.maxNumberOfCollaborators}
</span>
</div>
<Slider
id="slider-min-max-maker"
min={JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS}
max={JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS}
minStepsBetweenThumbs={1}
value={[
formWatch.minNumberOfCollaborators ??
defaultValues?.minNumberOfCollaborators ??
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
formWatch.maxNumberOfCollaborators ??
defaultValues?.maxNumberOfCollaborators ??
JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS,
]}
onValueChange={(values: number[]) => {
setValue('minNumberOfCollaborators', values[0])
setValue('maxNumberOfCollaborators', values[1])
}}
disabled={disabled}
/>
{formState.errors.minNumberOfCollaborators?.message ? (
<FieldError>
{/* TODO: i18n, "between {{ min }} and {{ max }}*/}Please provide a valid minimum value.
</FieldError>
) : null}
{formState.errors.maxNumberOfCollaborators?.message ? (
<FieldError>
{/* TODO: i18n, "between {{ min }} and {{ max }}*/}Please provide a valid maximum value.
</FieldError>
) : null}
</Field>
</div>
</div>
<div className="flex flex-col justify-center gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="slider-min-number-of-transactions-per-jar" className="flex flex-col items-start gap-0">
{/* TODO: i18n */}
<div className="flex items-center gap-2 font-medium">Transactions per Jar</div>
<div className="text-muted-foreground text-sm">
Minimum number of collaborative transactions per Jar.
</div>
</Label>
<span className="text-foreground">{formWatch.minNumberOfTransactionsPerJar}</span>
</div>
<Slider
id="slider-min-number-of-transactions-per-jar"
min={JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR}
max={JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR}
value={
formWatch.minNumberOfTransactionsPerJar === undefined
? undefined
: [formWatch.minNumberOfTransactionsPerJar]
}
onValueChange={(values: number[]) => setValue('minNumberOfTransactionsPerJar', values[0])}
disabled={disabled}
/>
</div>
<div className="flex flex-col justify-center gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="slider-rounding-chance-in-percent" className="flex flex-col items-start gap-0">
{/* TODO: i18n */}
<div className="flex items-center gap-2 font-medium">Round amount probability</div>
<div className="text-muted-foreground text-sm">
Probability that an intermediate transaction output amount is rounded to mimic human behavior.
</div>
</Label>
<span className="text-foreground">{formWatch.roundingChanceInPercent}%</span>
</div>
<Slider
id="slider-rounding-chance-in-percent"
min={JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT}
max={JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT}
value={
formWatch.roundingChanceInPercent === undefined ? undefined : [formWatch.roundingChanceInPercent]
}
onValueChange={(values: number[]) => setValue('roundingChanceInPercent', values[0])}
disabled={disabled}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<p className="text-muted-foreground text-sm">{t('scheduler.description_fees')}</p>
<Button type="submit" disabled={disabled || formState.isSubmitting} className="w-full" size="xxl">
{formState.isSubmitting ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_plan')}
</>
) : (
t('scheduler.button_plan')
)}
</Button>
{debug && (
<Card className="mt-8">
<CardHeader className="grid">
<DevBadge className="justify-self-end" />
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="overflow-scroll">
<code className="text-destructive">errors:</code>
<pre className="text-xs">
{JSON.stringify(formState.errors.maxNumberOfCollaborators?.message, null, 2)}
</pre>
<pre className="text-xs">
{JSON.stringify(formState.errors.minNumberOfCollaborators?.message, null, 2)}
</pre>
<pre className="text-xs">{JSON.stringify(formState.errors.destinations?.message, null, 2)}</pre>
<pre className="text-xs">
{JSON.stringify(formState.errors.destinations?.[0]?.address?.message, null, 2)}
</pre>
<pre className="text-xs">
{JSON.stringify(formState.errors.destinations?.[1]?.address?.message, null, 2)}
</pre>
<pre className="text-xs">
{JSON.stringify(formState.errors.destinations?.[2]?.address?.message, null, 2)}
</pre>
<pre className="text-xs">
{JSON.stringify(formState.errors.roundingChanceInPercent?.message, null, 2)}
</pre>
<pre className="text-xs">{JSON.stringify(formState.errors.includeMakerSessions?.message, null, 2)}</pre>
</div>
</CardContent>
</Card>
)}
</form>
)
}

View file

@ -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,
})
})
})

View file

@ -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<TumblerParameters> => {
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<SweepFormValues> => {
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()
}

View file

@ -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<typeof useJamWalletInfoContext>
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<TumblerPlanResponse>>(),
startTumbler: vi.fn<(input?: unknown) => Promise<unknown>>(),
stopTumbler: vi.fn<(input?: unknown) => Promise<unknown>>(),
deleteTumbler: vi.fn<(input?: unknown) => Promise<unknown>>(),
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<string, unknown> }) => (
<span>
{i18nKey}
{values ? `:${JSON.stringify(values)}` : ''}
</span>
),
useTranslation: () => ({
t: (key: string) => key,
t: (key: string, options?: Record<string, unknown>) => (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<typeof import('@/store/jamSettingsStore')>()),
useDeveloperMode: () => ({ enabled: true }),
}))
vi.mock('@/components/settings/fees/FeeConfigDialog', () => ({
FeeConfigDialog: ({ open }: { open: boolean }) => (open ? <div>fee-config-dialog</div> : null),
}))
vi.mock('@/components/sweep/SweepDestinationInputs', () => ({
SweepDestinationInputs: ({ disabled, form }: { disabled: boolean; form: UseFormReturn<SweepFormValues> }) => (
SweepDestinationInputs: ({
disabled,
setValue,
}: {
disabled: boolean
setValue: UseFormReturn<SweepFormValues>['setValue']
}) => (
<button
type="button"
disabled={disabled}
onClick={() => {
VALID_DESTINATIONS.forEach((address, index) => {
form.setValue(`destinations.${index}.address`, address, { shouldDirty: true, shouldValidate: true })
setValue(`destinations.${index}.address`, address, { shouldDirty: true, shouldValidate: true })
})
void form.trigger('destinations')
}}
>
fill-destinations
@ -167,17 +229,6 @@ vi.mock('@/components/sweep/SweepPreconditionAlert', () => ({
),
}))
vi.mock('@/components/sweep/SweepScheduleProgress', () => ({
SweepScheduleProgress: ({ isStopping, onStop }: { isStopping: boolean; onStop: () => void }) => (
<div>
schedule-progress:{String(isStopping)}
<button type="button" onClick={onStop}>
stop-sweep
</button>
</div>
),
}))
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> = {}): 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> = {}): 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> = {}): 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<string, unknown> = {}) => {
jmSessionStore.setState({
state: {
@ -309,7 +386,7 @@ const setSession = (overrides: Record<string, unknown> = {}) => {
})
}
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
// 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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
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(<SweepPage walletFileName="wallet.jmdat" />)
const result = render(<SweepPage walletFileName="wallet.jmdat" />)
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 } }

View file

@ -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<TumblerParameters> = {
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<TumblerPlanRequest>()
const [alertMessage, setAlertMessage] = useState<string>()
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<SweepFormValues, SweepResolverContext, SweepFormValues>({
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 <PageLoading />
}
@ -351,10 +271,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
onOpenChange={setShowFeeConfigDialog}
/>
<SweepStartConfirmDialog
open={showScheduleConfirmDialog}
onOpenChange={setShowScheduleConfirmDialog}
open={showScheduleConfirmDialog !== undefined}
onOpenChange={() => setShowScheduleConfirmDialog(undefined)}
onConfirm={startSchedule}
disabled={isStartDisabled || isWaitingSchedulerStart}
disabled={isStartDisabled}
isStarting={isWaitingSchedulerStart}
/>
<div className="mx-auto max-w-4xl space-y-3 p-4">
@ -388,25 +308,133 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
{isWaitingSchedulerStart && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.button_start')}</AlertTitle>
<AlertTitle>{t('scheduler.alert_scheduler_starting_title')}</AlertTitle>
</Alert>
)}
{isWaitingSchedulerStop && (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.button_stop')}</AlertTitle>
<AlertTitle>{t('scheduler.alert_scheduler_stopping_title')}</AlertTitle>
</Alert>
)}
{schedulerRunning && currentSchedule && (
<SweepScheduleProgress schedule={currentSchedule} isStopping={isWaitingSchedulerStop} onStop={stopSchedule} />
{!isWaitingSchedulerStart && !isWaitingSchedulerStop && !schedulerRunning && (
<SweepPreconditionAlert summary={preconditionSummary} />
)}
{!schedulerRunning && (
{currentSchedule && (
<>
<SweepPreconditionAlert summary={preconditionSummary} />
{getScheduleQuery.isPending ? (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
) : (
<>
{schedulerRunning && currentSchedule.summary.status.completed ? (
<Alert variant="success">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.progress_done_awaiting_stop')}</AlertTitle>
</Alert>
) : null}
<SweepScheduleProgress schedule={currentSchedule} debug={isDeveloperMode} />
{schedulerRunning && currentSchedule.summary.status.running ? (
<Button
type="button"
onClick={() => void stopSchedule()}
disabled={isWaitingSchedulerStop}
size="lg"
className="w-full"
>
{isWaitingSchedulerStop ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_stop')}
</>
) : (
t('scheduler.button_stop')
)}
</Button>
) : (
<>
{currentSchedule.summary.status.pending || isWaitingSchedulerStart ? (
<Button
type="button"
onClick={() => {
setShowScheduleConfirmDialog(getScheduleQuery.data)
}}
disabled={isStartDisabled}
size="lg"
className="w-full"
>
{isWaitingSchedulerStart ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_start')}
</>
) : (
t('scheduler.button_start')
)}
</Button>
) : null}
{currentSchedule.summary.status.pending && planSchedule.variables?.body ? (
<Button
type="button"
variant="secondary"
onClick={() => {
void planSchedule.mutateAsync({
path: { walletname: walletFileName },
body: planSchedule.variables.body,
})
}}
disabled={isStartDisabled}
size="lg"
className="w-full"
>
{planSchedule.isPending ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_plan_renew')}
</>
) : (
t('scheduler.button_plan_renew')
)}
</Button>
) : null}
<Button
type="button"
variant={currentSchedule.summary.derivedStatus.terminated ? 'default' : 'ghost'}
onClick={() => void deleteSchedule()}
disabled={
!getScheduleQuery.data ||
startScheduleMutationIsPending ||
planSchedule.isPending ||
deleteScheduleMutationIsPending
}
size="lg"
className="w-full"
>
{deleteScheduleMutationIsPending ? (
<>
<Spinner className="motion-reduce:hidden" />
{currentSchedule.summary.derivedStatus.terminated ? t('global.done') : t('global.cancel')}
</>
) : (
<>{currentSchedule.summary.derivedStatus.terminated ? t('global.done') : t('global.cancel')}</>
)}
</Button>
</>
)}
</>
)}
</>
)}
{!currentSchedule && !schedulerRunning && (
<>
<Card>
<CardContent className="space-y-5">
<div className="bg-muted/50 flex flex-col items-start justify-between gap-2 rounded-lg border px-4 py-3 sm:flex-row sm:items-center">
@ -421,54 +449,51 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
<p className="text-muted-foreground text-sm">{t('scheduler.description_destination_addresses')}</p>
{showInsecureScheduleTestingToggle && (
<div className="flex items-center gap-2">
<Switch
id="switch-use-insecure-schedule-testing"
checked={useInsecureTestingSettings}
onCheckedChange={onInsecureTestingToggleChange}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
/>
<Label htmlFor="switch-use-insecure-schedule-testing" className="flex flex-col items-start gap-0">
<div className="flex items-center gap-2 font-medium">
Use insecure testing settings
<DevBadge />
</div>
<div className="text-muted-foreground text-sm">
This is completely insecure but makes testing the schedule much faster.
</div>
</Label>
</div>
)}
<SweepDestinationInputs
form={form}
fields={fields}
<SweepForm
jars={walletInfo.jars}
addressSummary={walletInfo.addressSummary}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
debug={isDeveloperMode}
onSubmit={async (values) => {
const parameters: Partial<TumblerParameters> = {
...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,
})
}}
/>
<p className="text-muted-foreground text-sm">{t('scheduler.description_fees')}</p>
<Button
type="button"
onClick={() => void onOpenScheduleConfirm()}
disabled={isStartDisabled}
size="xxl"
className="w-full"
>
{isWaitingSchedulerStart ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_start')}
</>
) : (
t('scheduler.button_start')
)}
</Button>
</CardContent>
</Card>
</>
)}
{isDeveloperMode && (
<Card className="mt-8">
<CardHeader className="grid">
<DevBadge className="justify-self-end" />
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="overflow-scroll">
<code className="text-destructive">getScheduleQuery.data:</code>
<pre className="text-xs">{JSON.stringify(getScheduleQuery.data, null, 2)}</pre>
</div>
<div className="overflow-scroll">
<code className="text-destructive">planSchedule.data:</code>
<pre className="text-xs">{JSON.stringify(planSchedule.data, null, 2)}</pre>
</div>
</CardContent>
</Card>
)}
</div>
</>
)

View file

@ -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<string, unknown> }) => (
@ -16,40 +15,285 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
}))
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(<SweepScheduleProgress schedule={schedule} isStopping={false} onStop={onStop} />)
render(<SweepScheduleProgress schedule={schedule} />)
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(<SweepScheduleProgress schedule={schedule} isStopping={false} onStop={vi.fn()} />)
render(<SweepScheduleProgress schedule={schedule} />)
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(<SweepScheduleProgress schedule={schedule} isStopping onStop={vi.fn()} />)
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(<SweepScheduleProgress schedule={schedule} />)
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(<SweepScheduleProgress schedule={schedule} />)
expect(screen.getByText('scheduler.progress_tldr_hours:{"length":"2","hours":"2"}')).toBeInTheDocument()
expect(screen.getByText('Scheduled sweep cancelled.')).toBeInTheDocument()
})
})

View file

@ -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<void>
}
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 (
<div className="bg-muted flex h-3 w-full overflow-hidden rounded-full">
{progress.steps.map((step, index) => (
<div
key={index}
className={cn('border-r-background h-full border-r-[1px] transition-all last:border-r-0', {
'bg-primary/35': !step.isComplete && !step.isActive,
'bg-primary motion-safe:animate-pulse': step.isActive,
'bg-primary': step.isComplete,
'bg-primary/35': !step.completed && !step.active,
'bg-primary motion-safe:animate-pulse': step.active,
'bg-primary': step.completed,
})}
style={{ width: `${step.widthPercent}%` }}
/>
@ -33,39 +43,27 @@ const SweepProgressBar = ({ schedule }: { schedule: Schedule }) => {
)
}
export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepScheduleProgressProps) => {
const alertTitleComponents = {
'1': <span className="font-semibold" />,
'3': <span className="font-semibold" />,
}
const alertDescriptionWaitingForConfirmationsComponents = {
'1': <span className="font-mono" />,
}
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': <span className="font-semibold" />,
'3': <span className="font-semibold" />,
}
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<Tab>(() => 'all')
return (
<Card>
@ -75,110 +73,310 @@ export const SweepScheduleProgress = ({ schedule, isStopping, onStop }: SweepSch
<p className="text-sm">
<Trans
i18nKey="scheduler.progress_tldr_seconds"
values={{ length: progress.totalTransactions, seconds: totalSeconds }}
components={highlightedComponents}
values={{ length: schedule.entries.length.toLocaleString(), seconds: totalSeconds.toLocaleString() }}
components={alertTitleComponents}
/>
</p>
) : (
<p className="text-sm">
<Trans
i18nKey="scheduler.progress_tldr_hours"
values={{ length: progress.totalTransactions, hours: totalHours }}
components={highlightedComponents}
values={{ length: schedule.entries.length.toLocaleString(), hours: totalHours.toLocaleString() }}
components={alertTitleComponents}
/>
</p>
)}
<p className="text-muted-foreground text-xs">{t('scheduler.progress_description')}</p>
</div>
<SweepProgressBar schedule={schedule} />
<SweepProgressBar progress={schedule.progress} />
{progress.isDone ? (
{schedule.summary.status.completed ? (
<Alert variant="success">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('scheduler.progress_done')}</AlertTitle>
</Alert>
) : (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>
{/* Keep a stable fallback state so brief polling gaps never leave this header empty. */}
{progress.currentState?.type === 'waiting_before_next' ? (
<Trans
i18nKey="scheduler.progress_current_state_wait_before_next"
values={{
current: progress.currentState.currentTransaction,
total: progress.currentState.totalTransactions,
wait: formatWaitTime(progress.currentState.waitSeconds ?? 0),
}}
components={highlightedComponents}
/>
) : progress.currentState?.type === 'waiting_for_confirmation' ? (
<Trans
i18nKey="scheduler.progress_current_state_waiting_confirmation"
values={{
current: progress.currentState.currentTransaction,
total: progress.currentState.totalTransactions,
txid: progress.currentState.txid ?? '-',
}}
components={highlightedComponents}
/>
) : progress.currentState?.type === 'transaction_confirmed' ? (
<Trans
i18nKey="scheduler.progress_current_state_transaction_confirmed"
values={{
current: progress.currentState.currentTransaction,
total: progress.currentState.totalTransactions,
}}
components={highlightedComponents}
/>
) : (
<Trans
i18nKey="scheduler.progress_current_state_creating_next"
values={{
current: progress.currentTransactionIndex + 1,
total: progress.totalTransactions,
}}
components={highlightedComponents}
/>
)}
</AlertTitle>
<CheckCircle2Icon />
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep finished successfully.'}</AlertTitle>
<AlertDescription />
</Alert>
)}
) : null}
<div className="space-y-2 rounded-lg border p-3">
<div className="font-medium">{t('scheduler.progress_schedule_info_title')}</div>
<div className="space-y-2">
{progress.entries.map((entry) => (
<div
key={entry.index}
className="bg-muted/30 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded-md px-2 py-1.5 text-xs"
>
<div className="font-medium">{t('scheduler.progress_entry_label', { index: entry.index + 1 })}</div>
<div className="text-muted-foreground">{toScheduleEntryStateText(entry.state, entry.txid)}</div>
<div className="text-muted-foreground">
{entry.isLast
? t('scheduler.progress_entry_wait_final')
: t('scheduler.progress_entry_wait_before_next', {
wait: formatWaitTime(entry.waitBeforeNextSeconds),
})}
{schedule.summary.status.failed ? (
<Alert variant="destructive">
<AlertTriangleIcon />
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep failed.'}</AlertTitle>
<AlertDescription />
</Alert>
) : null}
{schedule.summary.status.cancelled ? (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep cancelled.'}</AlertTitle>
<AlertDescription />
</Alert>
) : null}
{schedule.summary.status.pending ? (
<Alert variant="default">
<ClockAlertIcon />
<AlertTitle>{/* TODO: i18n */ 'Scheduled sweep pending.'}</AlertTitle>
<AlertDescription />
</Alert>
) : 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' ? (
<Alert>
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>
<Trans
i18nKey="scheduler.progress_current_state_waiting_confirmation_title"
values={{
current: schedule.active ? (schedule.active.index + 1).toLocaleString() : undefined,
total: schedule.entries.length.toLocaleString(),
txid: schedule.active?.transactionId
? shortenStringMiddle(schedule.active.transactionId, 12)
: '-',
}}
components={alertTitleComponents}
/>
</AlertTitle>
<AlertDescription>
<Trans
i18nKey="scheduler.progress_current_state_waiting_confirmation_description"
values={{
txid: schedule.active?.transactionId
? shortenStringMiddle(schedule.active.transactionId, 12)
: '-',
}}
components={alertDescriptionWaitingForConfirmationsComponents}
/>
</AlertDescription>
</Alert>
) : schedule.summary.derivedStatus.value === 'waiting_before_next' ? (
<Alert>
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>
<Trans
i18nKey="scheduler.progress_current_state_waiting_start_title"
values={{
current: schedule.active ? (schedule.active.index + 1).toLocaleString() : undefined,
total: schedule.entries.length,
}}
components={alertTitleComponents}
/>
</AlertTitle>
<AlertDescription />
</Alert>
) : (
<Alert>
<Spinner className="motion-reduce:hidden" />
<AlertTitle>
<Trans
i18nKey="scheduler.progress_current_state_executing_title"
values={{
current: schedule.active ? (schedule.active.index + 1).toLocaleString() : undefined,
total: schedule.entries.length,
}}
components={alertTitleComponents}
/>
</AlertTitle>
<AlertDescription />
</Alert>
)}
</>
) : null}
{schedule.summary.startedAt ? (
<Item variant="outline">
<ItemContent className="grid gap-4 sm:grid-cols-2">
<div className="flex min-w-0 items-start gap-4">
<CalendarClockIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Started At'}</Label>
<span title={schedule.summary.startedAt.toISOString()}>
{schedule.summary.startedAt.toLocaleString()}
</span>
</div>
</div>
))}
</div>
</div>
<div className="flex min-w-0 items-start gap-4">
<CalendarCheck2Icon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Finished At'}</Label>
{schedule.summary.finishedAt === undefined ? (
'-'
) : (
<span title={schedule.summary.finishedAt.toISOString()}>
{schedule.summary.finishedAt.toLocaleString()}
</span>
)}
</div>
</div>
</ItemContent>
</Item>
) : null}
<Button type="button" onClick={() => void onStop()} disabled={isStopping} size="lg" className="w-full">
{isStopping ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('scheduler.button_stop')}
</>
) : (
t('scheduler.button_stop')
)}
</Button>
{schedule.active !== undefined ? (
<Accordion type="single" collapsible defaultValue="active">
<AccordionItem value="active">
<AccordionTrigger>
{
/* TODO: i18n */ t('scheduler.section_active_action_title', {
defaultValue: 'Active action',
})
}
</AccordionTrigger>
<AccordionContent
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
>
<ItemGroup className="space-y-2">
<ScheduleEntryItem value={schedule.active} active />
</ItemGroup>
</AccordionContent>
</AccordionItem>
</Accordion>
) : null}
{schedule.summary.externalDestinations.length === 0 ? null : (
<Accordion type="single" collapsible>
<AccordionItem value="destinations">
<AccordionTrigger>
{
/* TODO: i18n */ t('scheduler.section_destinations_title', {
defaultValue: 'Destinations',
})
}
</AccordionTrigger>
<AccordionContent
className={cn('flex flex-col gap-2 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
>
<p className="text-muted-foreground">{t('scheduler.description_destination_addresses')}</p>
{schedule.summary.externalDestinations.map((value, index) => {
return (
<Item key={index} variant={value.transactionId ? 'muted' : 'outline'}>
<ItemContent className="space-y-2">
<ItemTitle className="flex-col items-start gap-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Address'}</Label>
<Address value={value.address} />
</ItemTitle>
{value.transactionId ? (
<div className="flex flex-col gap-1">
<Label className="font-semibold">{/*TODO: i18n */ 'Transaction ID'}</Label>
<div className="flex items-center gap-2">
<span className="text-md block font-mono break-all select-all">
{value.transactionId}
</span>
<CopyButton
value={value.transactionId}
text={<CopyIcon />}
successText={<CheckIcon className="text-brand-success" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon-xs' }), 'shrink-0')}
/>
</div>
</div>
) : null}
</ItemContent>
</Item>
)
})}
</AccordionContent>
</AccordionItem>
</Accordion>
)}
<Accordion type="single" collapsible>
<AccordionItem value="details">
<AccordionTrigger>
{
/* TODO: i18n */ t('scheduler.section_details_title', {
defaultValue: 'Schedule details',
})
}
</AccordionTrigger>
<AccordionContent
className={cn('flex flex-col gap-6 py-2', 'mx-1' /* add x-spacing for input component focus state*/)}
>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as Tab)}
className="flex flex-col gap-4"
>
<TabsList className="mx-auto flex items-center gap-2">
<TabsTrigger value="pending" className="cursor-pointer">
{/* TODO: i18n */}Pending ({schedule.pending.length.toLocaleString()})
</TabsTrigger>
<TabsTrigger value="completed" className="cursor-pointer">
{/* TODO: i18n */}Completed ({schedule.completed.length.toLocaleString()})
</TabsTrigger>
<TabsTrigger value="all" className="cursor-pointer">
{/* TODO: i18n */}All ({schedule.entries.length.toLocaleString()})
</TabsTrigger>
</TabsList>
</Tabs>
{activeTab === 'completed' ? (
schedule.completed.length === 0 ? (
<>
<div className="m-2 flex items-center justify-center gap-2">
No completed actions yet.{/* TODO: i18n */}
</div>
</>
) : (
<ItemGroup className="space-y-2">
{schedule.completed.map((entry, index) => {
return <ScheduleEntryItem key={index} value={entry} active={entry === schedule.active} />
})}
</ItemGroup>
)
) : null}
{activeTab === 'pending' ? (
schedule.pending.length === 0 ? (
<>
<div className="m-2 flex items-center justify-center gap-2">
No pending actions.{/* TODO: i18n */}
</div>
</>
) : (
<ItemGroup className="space-y-2">
{schedule.pending.map((entry, index) => {
return <ScheduleEntryItem key={index} value={entry} active={entry === schedule.active} />
})}
</ItemGroup>
)
) : null}
{activeTab === 'all' ? (
<ItemGroup className="space-y-2">
{schedule.entries.map((entry, index) => {
return <ScheduleEntryItem key={index} value={entry} active={entry === schedule.active} />
})}
</ItemGroup>
) : null}
</AccordionContent>
</AccordionItem>
</Accordion>
{debug && (
<Card className="mt-8">
<CardContent className="flex flex-col gap-2">
<Accordion type="single" collapsible>
<AccordionItem value="debug">
<AccordionTrigger>
<div>
Debug <DevBadge className="justify-self-end" />
</div>
</AccordionTrigger>
<AccordionContent>
<div className="overflow-scroll">
<code className="text-destructive">schedule:</code>
<pre className="text-xs">{JSON.stringify(schedule, null, 2)}</pre>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
)}
</CardContent>
</Card>
)

View file

@ -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()
})

View file

@ -24,9 +24,9 @@ export const SweepStartConfirmDialog = ({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('scheduler.confirm_modal.title')}</DialogTitle>
<DialogTitle>{t('scheduler.confirm_start_modal.title')}</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">{t('scheduler.confirm_modal.body')}</p>
<p className="text-muted-foreground text-sm">{t('scheduler.confirm_start_modal.text_body')}</p>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isStarting}>
{t('modal.confirm_button_reject')}

View file

@ -25,8 +25,7 @@ export const buildDestinationErrors = (
}
const isDuplicateAddress = (counts.get(address) ?? 0) > 1
if (isReusedAddress(address, addressSummary) || isDuplicateAddress) {
if (isDuplicateAddress || isReusedAddress(address, addressSummary)) {
return t('scheduler.feedback_reused_destination_address')
}

View file

@ -1,3 +1,4 @@
import { JM_TAKER_UTXO_AGE } from '@/constants/jm'
import type { Utxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
@ -16,11 +17,11 @@ export interface SweepPreconditionSummary {
const DEFAULT_OPTIONS: SweepPreconditionOptions = {
minNumberOfUtxos: 1,
minConfirmations: 5,
minConfirmations: JM_TAKER_UTXO_AGE,
}
const isUtxoEligible = (utxo: Utxo): boolean => {
return !utxo.frozen && !fb.utxo.isLocked(utxo)
return !utxo.frozen && !fb.utxo.isFidelityBond(utxo)
}
const groupByJarIndex = (utxos: Utxo[]): Map<number, Utxo[]> => {
@ -33,7 +34,7 @@ const groupByJarIndex = (utxos: Utxo[]): Map<number, Utxo[]> => {
}, new Map<number, Utxo[]>())
}
const toRetryLockedUtxos = (eligibleUtxos: Utxo[]): Utxo[] => {
const filterUtxosBlockedByRetryLimit = (eligibleUtxos: Utxo[]): Utxo[] => {
const utxosByJar = groupByJarIndex(eligibleUtxos)
const blockedUtxos: Utxo[] = []
@ -47,7 +48,7 @@ const toRetryLockedUtxos = (eligibleUtxos: Utxo[]): Utxo[] => {
return blockedUtxos
}
const toNumberOfMissingConfirmations = (utxos: Utxo[], minConfirmations: number): number => {
const calcMissingConfirmations = (utxos: Utxo[], minConfirmations: number): number => {
if (utxos.length === 0) {
return 0
}
@ -71,9 +72,9 @@ export const buildSweepPreconditionSummary = (
const eligibleUtxos = utxos.filter((utxo) => isUtxoEligible(utxo))
const numberOfMissingUtxos = Math.max(0, mergedOptions.minNumberOfUtxos - eligibleUtxos.length)
const numberOfMissingConfirmations =
numberOfMissingUtxos > 0 ? 0 : toNumberOfMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations)
numberOfMissingUtxos > 0 ? 0 : calcMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations)
const retryLockedUtxos = toRetryLockedUtxos(eligibleUtxos)
const retryLockedUtxos = filterUtxosBlockedByRetryLimit(eligibleUtxos)
return {
isFulfilled: numberOfMissingUtxos === 0 && numberOfMissingConfirmations === 0 && retryLockedUtxos.length === 0,

View file

@ -1,74 +1,563 @@
import type { TumblerPlanResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { describe, expect, it } from 'vitest'
import {
isScheduleLikelyCompletedSuccessfully,
isScheduleValue,
toScheduleProgressSummary,
type Schedule,
} from './scheduleUtils'
import { toSchedule } from './scheduleUtils'
describe('scheduleUtils', () => {
it('identifies valid schedule values', () => {
expect(isScheduleValue([[0, 0, 8, 'INTERNAL', 10, 16, 0]])).toBe(true)
expect(isScheduleValue(undefined)).toBe(false)
expect(isScheduleValue([{}])).toBe(false)
})
it('creates schedule from tumbler plan', () => {
const response: TumblerPlanResponse = {
plan_id: 'any',
wallet_name: 'wallet.jmdat',
status: 'running',
destinations: ['external-address'],
current_phase: 0,
phases: [
{
kind: 'taker_coinjoin',
index: 0,
status: 'running',
wait_seconds: 20.8818214051562,
started_at: '2009-01-03T10:35:52.775747+00:00',
finished_at: null,
error: null,
mixdepth: 0,
amount: 0,
amount_fraction: null,
counterparty_count: 21,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'maker_session',
index: 1,
status: 'pending',
wait_seconds: 3.005975326668475,
started_at: null,
finished_at: null,
error: null,
mixdepth: null,
amount: null,
amount_fraction: null,
counterparty_count: null,
destination: null,
txid: null,
duration_seconds: 43200,
target_cj_count: null,
idle_timeout_seconds: 60,
cj_served: 0,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 2,
status: 'pending',
wait_seconds: 2.14866055202069,
started_at: null,
finished_at: null,
error: null,
mixdepth: 1,
amount: null,
amount_fraction: 0.3907,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 3,
status: 'pending',
wait_seconds: 0.676048133059885,
started_at: null,
finished_at: null,
error: null,
mixdepth: 1,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'maker_session',
index: 4,
status: 'pending',
wait_seconds: 1.5946546346591093,
started_at: null,
finished_at: null,
error: null,
mixdepth: null,
amount: null,
amount_fraction: null,
counterparty_count: null,
destination: null,
txid: null,
duration_seconds: 43200,
target_cj_count: null,
idle_timeout_seconds: 60,
cj_served: 0,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 5,
status: 'pending',
wait_seconds: 2.9896632224179864,
started_at: null,
finished_at: null,
error: null,
mixdepth: 2,
amount: null,
amount_fraction: 0.4218,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 6,
status: 'pending',
wait_seconds: 40.54038187838071,
started_at: null,
finished_at: null,
error: null,
mixdepth: 2,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'maker_session',
index: 7,
status: 'pending',
wait_seconds: 6.403509345560395,
started_at: null,
finished_at: null,
error: null,
mixdepth: null,
amount: null,
amount_fraction: null,
counterparty_count: null,
destination: null,
txid: null,
duration_seconds: 43200,
target_cj_count: null,
idle_timeout_seconds: 60,
cj_served: 0,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 8,
status: 'pending',
wait_seconds: 26.647805903403334,
started_at: null,
finished_at: null,
error: null,
mixdepth: 3,
amount: null,
amount_fraction: 0.5576,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 9,
status: 'pending',
wait_seconds: 2.4477304838778844,
started_at: null,
finished_at: null,
error: null,
mixdepth: 3,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'maker_session',
index: 10,
status: 'pending',
wait_seconds: 10.08820707258785,
started_at: null,
finished_at: null,
error: null,
mixdepth: null,
amount: null,
amount_fraction: null,
counterparty_count: null,
destination: null,
txid: null,
duration_seconds: 43200,
target_cj_count: null,
idle_timeout_seconds: 60,
cj_served: 0,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 11,
status: 'pending',
wait_seconds: 8.619765466535256,
started_at: null,
finished_at: null,
error: null,
mixdepth: 4,
amount: null,
amount_fraction: 0.5066,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 12,
status: 'pending',
wait_seconds: 0,
started_at: null,
finished_at: null,
error: null,
mixdepth: 4,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'different-external-address',
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,
}
it('creates progress summary from schedule entries', () => {
const schedule: Schedule = [
[0, 0, 8, 'INTERNAL', 10, 16, 1],
[1, 0, 8, 'INTERNAL', 5, 16, 0],
[2, 0, 8, 'bc1qdestination', 1, 16, 0],
]
const schedule = toSchedule(response, [])
const summary = toScheduleProgressSummary(schedule)
expect(schedule.completed.length).toBe(0)
expect(schedule.entries.length).toBe(13)
expect(summary.totalTransactions).toBe(3)
expect(summary.completedTransactions).toBe(1)
expect(summary.currentTransactionIndex).toBe(1)
expect(summary.isDone).toBe(false)
expect(summary.steps).toHaveLength(3)
expect(summary.steps[1].isActive).toBe(true)
expect(summary.entries).toHaveLength(3)
expect(summary.entries[0].state).toBe('confirmed')
expect(summary.entries[0].waitBeforeNextSeconds).toBe(600)
expect(summary.entries[2].isLast).toBe(true)
expect(schedule.active?.index).toBe(0)
expect(schedule.active?.transactionId).toBeUndefined()
expect(schedule.active?.internal).toBe(true)
expect(schedule.active?.isSweep).toBe(true)
expect(schedule.active?.jarIndex).toBe(0)
expect(schedule.active?.numberOfRequestedCounterparties).toBe(21)
expect(schedule.active?.status).toEqual({
value: 'running',
pending: false,
running: true,
completed: false,
failed: false,
cancelled: false,
skipped: false,
})
expect(schedule.summary.externalDestinations).toEqual([
{
address: 'different-external-address',
},
])
expect(schedule.summary.status).toEqual({
value: 'running',
pending: false,
running: true,
completed: false,
failed: false,
cancelled: false,
})
})
it('derives current state while waiting for transaction confirmation', () => {
const schedule: Schedule = [
[0, 0, 8, 'INTERNAL', 10, 16, 1],
[1, 0, 8, 'INTERNAL', 5, 16, '8'.repeat(64)],
[2, 0, 8, 'bc1qdestination', 1, 16, 0],
]
const response: TumblerPlanResponse = {
plan_id: 'any',
wallet_name: 'wallet.jmdat',
status: 'running',
destinations: ['bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'],
current_phase: 0,
phases: [
{
kind: 'taker_coinjoin',
index: 0,
status: 'completed',
wait_seconds: 20.8818214051562,
started_at: '2009-01-03T10:35:52.775747+00:00',
finished_at: null,
error: null,
mixdepth: 0,
amount: 0,
amount_fraction: null,
counterparty_count: 42,
destination: 'INTERNAL',
txid: '8'.repeat(64),
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 summary = toScheduleProgressSummary(schedule)
const schedule = toSchedule(response, [])
expect(schedule.completed.length).toBe(1)
expect(summary.currentState?.type).toBe('waiting_for_confirmation')
expect(summary.currentState?.currentTransaction).toBe(2)
expect(summary.entries[1].state).toBe('broadcasted')
expect(summary.entries[1].txid).toBe('8'.repeat(64))
expect(schedule.active?.index).toBe(0)
expect(schedule.active?.transactionId).toBe('8'.repeat(64))
expect(schedule.active?.internal).toBe(true)
expect(schedule.active?.isSweep).toBe(true)
expect(schedule.active?.jarIndex).toBe(0)
expect(schedule.active?.numberOfRequestedCounterparties).toBe(42)
expect(schedule.active?.status).toEqual({
value: 'completed',
pending: false,
running: false,
completed: true,
failed: false,
cancelled: false,
skipped: false,
})
expect(schedule.summary.derivedStatus).toEqual({
value: 'waiting_for_confirmation',
terminated: false,
})
expect(schedule.summary.status).toEqual({
value: 'running',
pending: false,
running: true,
completed: false,
failed: false,
cancelled: false,
})
})
it('derives current state while waiting before the next transaction', () => {
const schedule: Schedule = [
[0, 0, 8, 'INTERNAL', 2, 16, 1],
[1, 0, 8, 'bc1qdestination', 0, 16, 0],
]
const response: TumblerPlanResponse = {
plan_id: 'any',
wallet_name: 'wallet.jmdat',
status: 'running',
destinations: ['bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'],
current_phase: 1,
phases: [
{
kind: 'taker_coinjoin',
index: 0,
status: 'completed',
wait_seconds: 20.8818214051562,
started_at: '2009-01-03T10:35:52.775747+00:00',
finished_at: '2009-01-03T10:36:17.645587+00:00',
error: null,
mixdepth: 0,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'maker_session',
index: 1,
status: 'pending',
wait_seconds: 3.005975326668475,
started_at: null,
finished_at: null,
error: null,
mixdepth: null,
amount: null,
amount_fraction: null,
counterparty_count: null,
destination: null,
txid: null,
duration_seconds: 42,
target_cj_count: null,
idle_timeout_seconds: 60,
cj_served: 0,
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 summary = toScheduleProgressSummary(schedule)
const schedule = toSchedule(response, [])
expect(summary.currentState?.type).toBe('waiting_before_next')
expect(summary.currentState?.currentTransaction).toBe(2)
expect(summary.currentState?.waitSeconds).toBe(120)
expect(schedule.completed.length).toBe(1)
expect(schedule.summary.derivedStatus).toEqual({
value: 'waiting_before_next',
terminated: false,
})
expect(schedule.active?.index).toBe(1)
expect(schedule.active?.durationSeconds).toBe(42)
})
it('falls back to frozen-utxo check when last schedule entry is stale', () => {
const schedule: Schedule = [
[0, 0, 8, 'INTERNAL', 10, 16, 1],
[1, 0, 8, 'bc1qdestination', 0, 16, 0],
]
it('derives completed state', () => {
const response: TumblerPlanResponse = {
plan_id: 'any',
wallet_name: 'wallet.jmdat',
status: 'completed',
destinations: ['final-destination'],
current_phase: 4,
phases: [
{
kind: 'taker_coinjoin',
index: 0,
status: 'completed',
wait_seconds: 33.060614864497204,
started_at: '2009-01-03T10:37:22.070815+00:00',
finished_at: '2009-01-03T10:38:23.012313+00:00',
error: null,
mixdepth: 2,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'INTERNAL',
txid: '0c75c7593508f7a3535be252e06ac4274caaab3a26e44d170938a40c2057ba31',
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: 9.086152239558118,
started_at: '2009-01-03T10:45:56.185757+00:00',
finished_at: '2009-01-03T10:46:56.547960+00:00',
error: null,
mixdepth: 0,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'INTERNAL',
txid: '91b49b8a01a68ffb1638ee52579b83bab29ac3035efc20c4eb90133355c072f4',
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 2,
status: 'completed',
wait_seconds: 7.629039450908496,
started_at: '2009-01-03T10:47:35.670512+00:00',
finished_at: '2009-01-03T10:48:33.074154+00:00',
error: null,
mixdepth: 1,
amount: null,
amount_fraction: 0.5535,
counterparty_count: 1,
destination: 'INTERNAL',
txid: '556bafc2f01c363b5b40f0920a983d509860abc809562577c3cf45394ef705ca',
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
{
kind: 'taker_coinjoin',
index: 9,
status: 'completed',
wait_seconds: 0,
started_at: '2009-01-03T11:13:31.800857+00:00',
finished_at: '2009-01-03T11:14:35.691720+00:00',
error: null,
mixdepth: 4,
amount: 0,
amount_fraction: null,
counterparty_count: 1,
destination: 'final-destination',
txid: '1f95d0ff8b43645777b60bc23544b955be7e4d8984bb66856747f1fe5674df00',
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
},
],
created_at: '2009-01-03T10:37:19.808342+00:00',
updated_at: '2009-01-03T11:14:35.709755+00:00',
error: null,
stale: false,
}
expect(isScheduleLikelyCompletedSuccessfully(schedule, false)).toBe(false)
expect(isScheduleLikelyCompletedSuccessfully(schedule, true)).toBe(true)
const schedule = toSchedule(response, [])
expect(schedule.completed.length).toBe(4)
expect(schedule.summary.derivedStatus).toEqual({
value: 'completed',
terminated: true,
})
expect(schedule.active).toBeUndefined()
expect(schedule.summary.externalDestinations).toEqual([
{
address: 'final-destination',
transactionId: '1f95d0ff8b43645777b60bc23544b955be7e4d8984bb66856747f1fe5674df00',
},
])
})
})

View file

@ -1,251 +1,325 @@
import { isValidNumber } from '@/lib/utils'
import type { TumblerPhaseResponse, TumblerPlanResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { TFunction } from 'i18next'
import { JM_TUMBLER_MIN_CONFIRMATIONS_BETWEEN_PHASES } from '@/constants/jm'
import type { Jar } from '@/context/JamWalletInfoContext'
import { MEAN_DURATION_BETWEEN_BLOCKS_SECONDS, percentageToFactor } from '@/lib/utils'
import type { TxId } from '@/store/jmTxStore'
import type { BitcoinAddress, JarIndex, Minutes } from '@/types/global'
import type { BitcoinAddress, Factor, JarIndex, Seconds } from '@/types/global'
type AmountFraction = number
type AmountCounterparties = number
type SchedulerDestinationAddress = 'INTERNAL' | BitcoinAddress
type WaitTimeInMinutes = Minutes
type Rounding = number
type StateFlag = 0 | 1 | TxId
type ScheduleEntryKind = 'taker_coinjoin' | 'maker_session' | TumblerPhaseResponse['kind']
type ScheduleEntryStatus =
'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'skipped' | TumblerPhaseResponse['status']
type ScheduleStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | TumblerPlanResponse['status']
// [mixdepth, amount-fraction, N-counterparties (requested), destination address, wait time in minutes, rounding, flag indicating incomplete/broadcast/completed (0/txid/1)]
// e.g.
// - [ 2, 0.2456498211214867, 4, "INTERNAL", 0.01, 16, 1 ]
// - [ 3, 0, 8, "bcrt1qpnv3nze7u6ecw63mn06ksxh497a3lryagh233q", 0.04, 16, 0 ]
export type ScheduleEntry = [
JarIndex,
AmountFraction,
AmountCounterparties,
SchedulerDestinationAddress,
WaitTimeInMinutes,
Rounding,
StateFlag,
]
export type TakerEntryDetails = {
jarIndex: JarIndex
jar?: Jar
amountFraction?: number
isSweep: boolean
numberOfRequestedCounterparties: AmountCounterparties // N-counterparties (requested)
} & (
| {
internal: true
externalDestinationAddress: undefined
}
| {
internal: false
externalDestinationAddress: BitcoinAddress
}
)
export type Schedule = ScheduleEntry[]
export interface ScheduleProgressStep {
widthPercent: number
isComplete: boolean
isActive: boolean
isFirst: boolean
isLast: boolean
export type MakerEntryDetails = {
durationSeconds: Seconds
idleTimeoutSeconds: Seconds
}
export type ScheduleEntryState = 'pending' | 'broadcasted' | 'confirmed'
export interface ScheduleProgressEntry {
export type ScheduleEntry = {
index: number
waitBeforeNextSeconds: number
state: ScheduleEntryState
txid?: TxId
isLast: boolean
kind: ScheduleEntryKind
startedAt?: Date
finishedAt?: Date
waitTimeInSeconds: Seconds
status: {
value: ScheduleEntryStatus
pending: boolean
running: boolean
completed: boolean
failed: boolean
cancelled: boolean
skipped: boolean
}
derivedStatus: {
terminated: boolean
}
transactionId?: TxId
__raw?: TumblerPhaseResponse // TODO: not optional
} & Partial<TakerEntryDetails> &
Partial<MakerEntryDetails>
export type ScheduleDerivedStatus = 'waiting_before_next' | 'waiting_for_confirmation'
type ExternalDestination = {
address: BitcoinAddress
transactionId?: TxId
}
export type ScheduleCurrentStateType =
'waiting_before_next' | 'creating_and_broadcasting' | 'waiting_for_confirmation' | 'transaction_confirmed'
export interface ScheduleCurrentState {
type: ScheduleCurrentStateType
currentTransaction: number
totalTransactions: number
waitSeconds?: number
txid?: TxId
export interface ScheduleSummary {
status: {
value: ScheduleStatus
pending: boolean
running: boolean
completed: boolean
failed: boolean
cancelled: boolean
}
startedAt?: Date
finishedAt?: Date
derivedStatus: {
value: ScheduleDerivedStatus | ScheduleStatus
terminated: boolean
}
totalWaitDurationBetweenPhasesInSeconds: Seconds
estimatedTotalDurationInSeconds: Seconds
externalDestinations: ExternalDestination[]
}
export interface ScheduleProgressSummary {
totalWaitSeconds: number
totalTransactions: number
completedTransactions: number
currentTransactionIndex: number
isDone: boolean
steps: ScheduleProgressStep[]
entries: ScheduleProgressEntry[]
currentState?: ScheduleCurrentState
}
const MIN_STEP_WIDTH_PERCENT = 8
const toNumberOrDefault = (value: unknown, fallback: number): number => {
return isValidNumber(value) ? value : fallback
export interface ScheduleProgressStep {
widthPercent: number
completed: boolean
active: boolean
}
const getScheduleEntryWaitMinutes = (entry: ScheduleEntry): number => {
return Math.max(0, toNumberOrDefault(entry[4], 0))
}
const getScheduleEntryState = (entry: ScheduleEntry): string | number => {
return entry[6] ?? 0
}
const getScheduleEntryTxId = (entry: ScheduleEntry): TxId | undefined => {
const state = getScheduleEntryState(entry)
return typeof state === 'string' ? state : undefined
}
// Scheduler state flag convention from backend:
// 0 => not yet broadcast, txid string => broadcasted (unconfirmed), 1 => confirmed.
const toScheduleEntryState = (entry: ScheduleEntry): ScheduleEntryState => {
const state = getScheduleEntryState(entry)
if (state === 1) {
return 'confirmed'
// TODO: refactor to object with summary props
export type Schedule = {
summary: ScheduleSummary
progress: {
steps: ScheduleProgressStep[]
}
if (typeof state === 'string') {
return 'broadcasted'
}
return 'pending'
active?: ScheduleEntry
entries: ScheduleEntry[]
// all pending entries
pending: ScheduleEntry[]
// all successfully completed entries
completed: ScheduleEntry[]
}
const isScheduleEntryConfirmed = (entry: ScheduleEntry): boolean => {
return getScheduleEntryState(entry) === 1
const FIRST_STEP_WIDTH_PERCENT = 5
const MIN_STEP_WIDTH_PERCENT = 3
const MIN_ESTIMATE_ACTION_RUNTIME_IN_SECONDS: Seconds = 60
const TIME_ESTIMATE_FACTOR: Factor = 1.1
const MEAN_CONFIMRATION_WAIT_TIME_BETWEEN_PHASES_SECONDS: Seconds =
JM_TUMBLER_MIN_CONFIRMATIONS_BETWEEN_PHASES * MEAN_DURATION_BETWEEN_BLOCKS_SECONDS
export const isPlanTerminated = (plan: TumblerPlanResponse) => {
return plan.stale === false && plan.status !== 'pending' && plan.status !== 'running'
}
export const isScheduleEntrySuccessful = (entry: ScheduleEntry): boolean => {
const state = getScheduleEntryState(entry)
return state === 1 || typeof state === 'string'
}
export const toSchedule = (plan: TumblerPlanResponse, jars: Jar[]): Schedule => {
const entries = plan.phases.map((it) => toScheduleEntry(it, jars))
const pending = entries.filter((it) => it.status.pending === true)
const completed = entries.filter((it) => it.status.completed === true)
export const isScheduleValue = (value: unknown): value is Schedule => {
return (
Array.isArray(value) &&
value.every(
(entry) =>
Array.isArray(entry) &&
entry.length >= 7 &&
typeof entry[0] === 'number' /* JarIndex */ &&
typeof entry[1] === 'number' /* AmountFraction */ &&
typeof entry[2] === 'number' /* AmountCounterparties */ &&
typeof entry[3] === 'string' /* SchedulerDestinationAddress */ &&
typeof entry[4] === 'number' /* WaitTimeInMinutes */ &&
typeof entry[5] === 'number' /* Rounding */ &&
(typeof entry[6] === 'number' || typeof entry[6] === 'string') /* StateFlag */,
)
const totalWaitDurationBetweenPhasesInSeconds: Seconds = Math.ceil(
Math.max(
1,
entries.slice(0, Math.max(0, entries.length - 1)).reduce((acc, entry) => {
return acc + entry.waitTimeInSeconds
}, 0),
),
)
const totalEstimatedDurationBetweenPhasesInSeconds: Seconds = Math.ceil(
Math.max(
1,
entries.slice(0, Math.max(0, entries.length - 1)).reduce((acc, entry) => {
const fixedRuntimeInSeconds = Math.max(
MIN_ESTIMATE_ACTION_RUNTIME_IN_SECONDS,
Math.min(entry.durationSeconds ?? 0, entry.idleTimeoutSeconds ?? 0),
)
const waitTime = Math.max(entry.waitTimeInSeconds, MEAN_CONFIMRATION_WAIT_TIME_BETWEEN_PHASES_SECONDS)
const estimatedActionRuntimeInSeconds = fixedRuntimeInSeconds + waitTime
return acc + estimatedActionRuntimeInSeconds
}, 0),
),
)
const estimatedTotalDurationInSeconds: Seconds = Math.ceil(
TIME_ESTIMATE_FACTOR * totalEstimatedDurationBetweenPhasesInSeconds,
)
const externalDestinations: ExternalDestination[] = entries
.filter((it) => it.externalDestinationAddress !== undefined)
.map((it) => ({
address: it.externalDestinationAddress!,
transactionId: it.transactionId,
}))
const status: ScheduleSummary['status'] = {
value: plan.status,
pending: plan.status === 'pending',
running: plan.status === 'running',
completed: plan.status === 'completed',
failed: plan.status === 'failed',
cancelled: plan.status === 'cancelled',
}
const active = !status.running ? undefined : entries.find((it) => it.index === plan.current_phase)
const startedAt = entries.at(0)?.startedAt
const finishedAt = entries.at(-1)?.finishedAt
const summary: ScheduleSummary = {
status,
startedAt,
finishedAt,
derivedStatus: {
value: toScheduleDerivedStatus(entries, active) ?? plan.status,
terminated: isPlanTerminated(plan),
},
totalWaitDurationBetweenPhasesInSeconds,
estimatedTotalDurationInSeconds,
externalDestinations,
}
const progress = toScheduleProgressSummary(summary, entries, active)
return {
summary,
progress,
active,
completed,
pending,
entries,
}
}
export const toScheduleProgressSummary = (schedule: Schedule): ScheduleProgressSummary => {
if (schedule.length === 0) {
return {
totalWaitSeconds: 0,
totalTransactions: 0,
completedTransactions: 0,
currentTransactionIndex: 0,
isDone: true,
steps: [],
entries: [],
export const toScheduleEntry = (phase: TumblerPhaseResponse, jars: Jar[]): ScheduleEntry => {
const status: ScheduleEntry['status'] = {
value: phase.status,
pending: phase.status === 'pending',
completed: phase.status === 'completed',
running: phase.status === 'running',
failed: phase.status === 'failed',
cancelled: phase.status === 'cancelled',
skipped: phase.status === 'skipped',
}
let value: ScheduleEntry = {
index: phase.index,
kind: phase.kind,
status,
derivedStatus: {
terminated: !status.pending && !status.running,
},
startedAt: phase.started_at ? new Date(Date.parse(phase.started_at)) : undefined,
finishedAt: phase.finished_at ? new Date(Date.parse(phase.finished_at)) : undefined,
waitTimeInSeconds: phase.wait_seconds ?? 0,
transactionId: phase.txid ?? undefined,
__raw: phase,
}
if (phase.kind === 'taker_coinjoin') {
const internal = phase.destination?.toUpperCase() === 'INTERNAL'
const details: TakerEntryDetails = {
jarIndex: phase.mixdepth ?? -1,
jar: jars.find((it) => it.jarIndex === phase.mixdepth),
isSweep: phase.amount === 0,
amountFraction: phase.amount_fraction ?? undefined,
numberOfRequestedCounterparties: phase.counterparty_count ?? 0,
...(internal === true
? {
internal: true,
externalDestinationAddress: undefined,
}
: {
internal: false,
externalDestinationAddress: phase.destination!,
}),
}
value = { ...value, ...details }
}
if (phase.kind === 'maker_session') {
const details: MakerEntryDetails = {
durationSeconds: phase.duration_seconds ?? 0,
idleTimeoutSeconds: phase.idle_timeout_seconds ?? 0,
}
value = { ...value, ...details }
}
return value
}
const completedTransactions = schedule.reduce((acc, entry) => {
return acc + (isScheduleEntryConfirmed(entry) ? 1 : 0)
}, 0)
const totalWaitSeconds = Math.max(
1,
schedule.slice(0, Math.max(0, schedule.length - 1)).reduce((acc, entry) => {
return acc + getScheduleEntryWaitMinutes(entry) * 60
}, 0),
const toScheduleProgressSummary = (
summary: ScheduleSummary,
entries: ScheduleEntry[],
activeEntry: ScheduleEntry | undefined,
): ScheduleProgressSummary => {
const entriesByIndex = entries.reduce(
(acc, item) => {
acc[item.index] = item
return acc
},
{} as { [key: number]: ScheduleEntry },
)
const isDone = completedTransactions >= schedule.length
const currentTransactionIndex = Math.min(completedTransactions, schedule.length - 1)
const remainder = Math.max(
1,
(1 - percentageToFactor(FIRST_STEP_WIDTH_PERCENT)) * summary.totalWaitDurationBetweenPhasesInSeconds,
)
const steps: ScheduleProgressStep[] = schedule.map((_entry, index) => {
const steps: ScheduleProgressStep[] = Object.values(entriesByIndex).map((entry) => {
const previousEntry: ScheduleEntry | undefined = entriesByIndex[entry.index - 1]
const widthPercent =
index === 0
? MIN_STEP_WIDTH_PERCENT
: Math.max(
MIN_STEP_WIDTH_PERCENT,
((Math.max(1, getScheduleEntryWaitMinutes(schedule[index - 1])) * 60) / totalWaitSeconds) * 100,
)
entry.index === 0
? FIRST_STEP_WIDTH_PERCENT
: Math.max(MIN_STEP_WIDTH_PERCENT, ((previousEntry?.waitTimeInSeconds ?? 0) / remainder) * 100)
return {
widthPercent,
isComplete: completedTransactions > index,
isActive: !isDone && completedTransactions === index,
isFirst: index === 0,
isLast: index === schedule.length - 1,
completed: entry.status.completed === true,
active: entry === activeEntry,
}
})
const entries: ScheduleProgressEntry[] = schedule.map((entry, index) => {
return {
index,
waitBeforeNextSeconds: index >= schedule.length - 1 ? 0 : getScheduleEntryWaitMinutes(entry) * 60,
state: toScheduleEntryState(entry),
txid: getScheduleEntryTxId(entry),
isLast: index === schedule.length - 1,
}
})
let currentState: ScheduleCurrentState | undefined
if (!isDone) {
const activeEntry = schedule[currentTransactionIndex]
const activeEntryTxId = getScheduleEntryTxId(activeEntry)
const activeEntryState = getScheduleEntryState(activeEntry)
// Infer the user-facing phase from the currently active entry and the previous wait slot.
if (activeEntryTxId !== undefined) {
currentState = {
type: 'waiting_for_confirmation',
currentTransaction: currentTransactionIndex + 1,
totalTransactions: schedule.length,
txid: activeEntryTxId,
}
} else if (activeEntryState === 1) {
currentState = {
type: 'transaction_confirmed',
currentTransaction: currentTransactionIndex + 1,
totalTransactions: schedule.length,
}
} else {
const waitSeconds =
currentTransactionIndex > 0
? Math.ceil(getScheduleEntryWaitMinutes(schedule[currentTransactionIndex - 1]) * 60)
: 0
currentState =
waitSeconds > 0
? {
type: 'waiting_before_next',
currentTransaction: currentTransactionIndex + 1,
totalTransactions: schedule.length,
waitSeconds,
}
: {
type: 'creating_and_broadcasting',
currentTransaction: currentTransactionIndex + 1,
totalTransactions: schedule.length,
}
}
}
return {
totalWaitSeconds,
totalTransactions: schedule.length,
completedTransactions: Math.min(completedTransactions, schedule.length),
currentTransactionIndex,
isDone,
steps,
entries,
currentState,
}
}
export const isScheduleLikelyCompletedSuccessfully = (schedule: Schedule, allUtxosFrozen: boolean): boolean => {
if (schedule.length === 0) {
return false
const toScheduleDerivedStatus = (
entries: ScheduleEntry[],
activeEntry: ScheduleEntry | undefined,
): ScheduleDerivedStatus | undefined => {
// Infer the user-facing phase from the currently active entry and the previous wait slot.
if (activeEntry?.transactionId !== undefined) {
return 'waiting_for_confirmation'
}
const entriesBeforeLastSucceeded = schedule.slice(0, -1).every((entry) => isScheduleEntrySuccessful(entry))
const lastEntry = schedule.at(-1)
if (lastEntry === undefined) {
return false
const previousEntry = activeEntry && entries.find((it) => it.index === activeEntry.index - 1)
if (previousEntry?.status.completed === true && activeEntry?.startedAt === undefined) {
return 'waiting_before_next'
}
const lastEntrySucceeded = isScheduleEntrySuccessful(lastEntry)
return entriesBeforeLastSucceeded && (lastEntrySucceeded || allUtxosFrozen)
return undefined
}
// TODO: move to utils and use Intl.DurationFormat (es2025) if available
export const formatDuration = (seconds: number, t: TFunction): string => {
const roundedSeconds = Math.max(0, Math.ceil(seconds))
const minutes = Math.floor(roundedSeconds / 60)
const remainingSeconds = roundedSeconds % 60
if (minutes === 0) {
return t('global.duration_seconds', { seconds: remainingSeconds.toLocaleString() })
}
if (remainingSeconds === 0) {
return t('global.duration_minutes', { minutes: minutes.toLocaleString() })
}
return t('global.duration_minutes_seconds', {
minutes: minutes.toLocaleString(),
seconds: remainingSeconds.toLocaleString(),
})
}

View file

@ -1,7 +1,7 @@
import { cva, type VariantProps } from 'class-variance-authority'
import type { JarIndex } from '@/types/global'
const badgeVariants = cva(
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
@ -18,6 +18,10 @@ const badgeVariants = cva(
jar3: 'border-transparent bg-jar3 text-primary-foreground [a&]:hover:bg-jar3/90',
jar4: 'border-transparent bg-jar4 text-primary-foreground [a&]:hover:bg-jar4/90',
jarUnknown: 'border-transparent bg-jar-unknown text-primary-foreground [a&]:hover:bg-jar-unknown/90',
muted: 'border-transparent bg-muted text-muted-foreground [a&]:hover:bg-muted/90',
success: 'border-transparent bg-brand-success text-brand-success-foreground [a&]:hover:bg-brand-success/90',
warning: 'border-transparent bg-brand-warning text-brand-warning-foreground [a&]:hover:bg-brand-warning/90',
info: 'border-transparent bg-brand-info text-brand-info-foreground [a&]:hover:bg-brand-info/90',
},
},
defaultVariants: {
@ -26,7 +30,7 @@ const badgeVariants = cva(
},
)
type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>
const JAR_BADGE_VARIANTS: Record<JarIndex, BadgeVariant> = {
0: 'jar0',
@ -36,7 +40,5 @@ const JAR_BADGE_VARIANTS: Record<JarIndex, BadgeVariant> = {
4: 'jar4',
}
const jarBadgeVariant = (jarIndex?: JarIndex): BadgeVariant | undefined =>
export const jarBadgeVariant = (jarIndex?: JarIndex): BadgeVariant | undefined =>
jarIndex === undefined ? undefined : (JAR_BADGE_VARIANTS[jarIndex] ?? 'jarUnknown')
export { badgeVariants, jarBadgeVariant }

157
src/components/ui/item.tsx Normal file
View file

@ -0,0 +1,157 @@
import * as React from 'react'
import * as Slot from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div role="list" data-slot="item-group" className={cn('group/item-group flex flex-col', className)} {...props} />
)
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return <Separator data-slot="item-separator" orientation="horizontal" className={cn('my-0', className)} {...props} />
}
const itemVariants = cva(
'group/item flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'gap-4 p-4',
sm: 'gap-2.5 px-4 py-3',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 rounded-sm border bg-muted [&_svg:not([class*='size-'])]:size-4",
image: 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn('flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none', className)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn('flex w-fit items-center gap-2 text-sm leading-snug font-medium', className)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="item-actions" className={cn('flex items-center gap-2', className)} {...props} />
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn('flex basis-full items-center justify-between gap-2', className)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}

View file

@ -0,0 +1,53 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
[value, defaultValue, min, max],
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full')}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View file

@ -1,7 +1,13 @@
import { percentageToFactor, parseSemanticVersion } from '@/lib/utils'
import type { AmountSats, Milliseconds } from '@/types/global'
import type { AmountSats, Milliseconds, Seconds } from '@/types/global'
import { version as packageInfoVersion } from '../../package.json'
import { JM_API_AUTH_TOKEN_EXPIRY, JM_DUST_THRESHOLD, JM_WALLET_FILE_EXTENSION } from './jm'
import {
JM_API_AUTH_TOKEN_EXPIRY,
JM_DUST_THRESHOLD,
JM_MINIMUM_MAKERS_DEFAULT,
JM_NG_DEFAULT_TUMBLER_PARAMS,
JM_WALLET_FILE_EXTENSION,
} from './jm'
import { parseAsIntOrDefault } from './meta-env-utils'
export const APP_DISPLAY_VERSION = (() => {
@ -102,6 +108,65 @@ export const JAM_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX: Milliseconds = Math.max(
60_000,
)
export const JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS: Seconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS, 60 * 60),
1,
)
const JAM_SWEEP_MAKER_SESSION_IDLE_DEFAULT_TIMEOUT_SECONDS: Seconds =
JM_NG_DEFAULT_TUMBLER_PARAMS.maker_session_seconds - 15 * 60
export const JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS: Seconds = Math.max(
parseAsIntOrDefault(
import.meta.env.VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS,
JAM_SWEEP_MAKER_SESSION_IDLE_DEFAULT_TIMEOUT_SECONDS,
),
JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS,
)
export const JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT: number = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT, 3),
1,
)
export const JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT: Seconds = Math.max(
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT, 3),
JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT,
)
// see https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.2/tumbler/src/tumbler/plan.py#L176 (last checked 2026-07-22)
export const JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS = 20
export const JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS = Math.max(
Math.min(
parseAsIntOrDefault(import.meta.env.VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS, JM_MINIMUM_MAKERS_DEFAULT),
JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS,
),
1,
)
// see https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.2/tumbler/src/tumbler/plan.py#L177 (last checked 2026-07-22)
export const JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS = JAM_SWEEP_MAX_MIN_NUMBER_OF_COLLABORATORS
export const JAM_SWEEP_MIN_MAX_NUMBER_OF_COLLABORATORS = JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS
export const JAM_SWEEP_MIN_TRANSACTIONS_PER_JAR = 2
export const JAM_SWEEP_MAX_TRANSACTIONS_PER_JAR = 8
export const JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT: number = 0
export const JAM_SWEEP_DEFAULT_MAX_ROUNDING_CHANCE_PERCENT: number = 100
export const JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT: number = Math.max(
parseAsIntOrDefault(
import.meta.env.VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT,
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
),
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
)
export const JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT = Math.max(
parseAsIntOrDefault(
import.meta.env.VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT,
JAM_SWEEP_DEFAULT_MAX_ROUNDING_CHANCE_PERCENT,
),
JAM_SWEEP_DEFAULT_MIN_ROUNDING_CHANCE_PERCENT,
)
/**
* A gaplimit threshold at which a warning is displayed that with the given value a
* decline in performance is to be expected. Importing 500 addresses (per jar!) leads to

View file

@ -1,4 +1,4 @@
import type { AmountSats, Milliseconds } from '@/types/global'
import type { AmountSats, Factor, Milliseconds, Seconds } from '@/types/global'
import { parseAsIntOrDefault } from './meta-env-utils'
export const JM_WALLET_FILE_EXTENSION = '.jmdat'
@ -35,6 +35,14 @@ export const JM_TX_FEES_FACTOR_DEFAULT = 0.2
export const JM_GAPLIMIT_DEFAULT = 6
// initial value for `TAKER__UTXO_AGE` (last checked on 2026-07-19)
// https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.1/jmcore/src/jmcore/settings.py#L877C5-L877C19
export const JM_TAKER_UTXO_AGE = 5
// initial value for `TUMBLER__MIN_CONFIRMATIONS_BETWEEN_PHASES` (last checked on 2026-07-19)
// https://github.com/joinmarket-ng/joinmarket-ng/blob/0.34.1/jmcore/src/jmcore/settings.py#L1031
export const JM_TUMBLER_MIN_CONFIRMATIONS_BETWEEN_PHASES = 6
export const JM_GAPLIMIT_CONFIGKEY: ConfigKey = {
section: 'POLICY',
field: 'gaplimit',
@ -66,3 +74,52 @@ export const FEE_CONFIG_KEYS: Record<FeeConfigName, ConfigKey> = {
max_cj_fee_rel: { section: 'POLICY', field: 'max_cj_fee_rel' },
max_sweep_fee_change: { section: 'POLICY', field: 'max_sweep_fee_change' },
}
// https://github.com/joinmarket-ng/joinmarket-ng/blob/0.33.0/tumbler/src/tumbler/builder.py#L49 (last checked: 2026-07-14)
export interface TumblerParameters {
maker_count_min: number
maker_count_max: number
// Average wait between phases (mean of an exponential distribution)
time_lambda_seconds: Seconds
// Multiplier on ``time_lambda_seconds`` for stage-1 (cleavage) sweeps
stage1_wait_multiplier: Factor
include_maker_sessions: boolean
mincjamount_sats: AmountSats
maker_session_seconds: Seconds
/**
* If set, maker phases exit successfully when no CoinJoin has been served
* within this many seconds. Useful as a safety fallback when the wallet is
* never selected as a counterparty.
*/
maker_session_idle_timeout_seconds: Seconds | undefined
// Minimum number of destination-bearing taker CJs per mixdepth (excluding sweep).
mintxcount: number
// Maximum re-tries per failed taker CoinJoin phase before the plan fails
max_phase_retries: number
// Probability that any given non-sweep taker CJ amount is rounded to a random number of significant figures. Set to 0.0 to disable rounding entirely.
rounding_chance: Factor
}
export const JM_NG_DEFAULT_TUMBLER_PARAMS: TumblerParameters = {
maker_count_min: 5, // default: 5
maker_count_max: 9, // default: 9
// Average wait between phases (mean of an exponential distribution)
time_lambda_seconds: 6 * 60 * 60, // default: 6 hours
// Multiplier on ``time_lambda_seconds`` for stage-1 (cleavage) sweeps
stage1_wait_multiplier: 3, // default: 3.0
include_maker_sessions: true, // default: true
mincjamount_sats: 100_000, // default: 100_000
maker_session_seconds: 12 * 60 * 60, // default: 12.0 * 60.0 * 60.0
/**
* If set, maker phases exit successfully when no CoinJoin has been served
* within this many seconds. Useful as a safety fallback when the wallet is
* never selected as a counterparty.
*/
maker_session_idle_timeout_seconds: undefined, // default: undefined
// Minimum number of destination-bearing taker CJs per mixdepth (excluding sweep).
mintxcount: 2, // default: 2
// Maximum re-tries per failed taker CoinJoin phase before the plan fails
max_phase_retries: 3, // default: 3
// Probability that any given non-sweep taker CJ amount is rounded to a random number of significant figures. Set to 0.0 to disable rounding entirely.
rounding_chance: 0.25, // default: 0.25
}

View file

@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest'
import { parseAsIntOrDefault } from './meta-env-utils'
describe('parseAsIntOrDefault', () => {
it('should parse input as int or default', () => {
expect(parseAsIntOrDefault(0, 21)).toBe(0)
expect(parseAsIntOrDefault(-1, 21)).toBe(-1)
expect(parseAsIntOrDefault(1, 21)).toBe(1)
expect(parseAsIntOrDefault('-1', 21)).toBe(-1)
expect(parseAsIntOrDefault('0', 21)).toBe(0)
expect(parseAsIntOrDefault('1', 21)).toBe(1)
expect(parseAsIntOrDefault('1.12231', 21)).toBe(1)
expect(parseAsIntOrDefault(1.12231, 21)).toBe(1)
expect(parseAsIntOrDefault(9.99999, 21)).toBe(9)
expect(parseAsIntOrDefault(Number.MAX_VALUE /* '1.7976931348623157e+308' */, 21)).toBe(1)
expect(parseAsIntOrDefault(Number.MIN_VALUE /* '5e-324' */, 21)).toBe(5)
expect(parseAsIntOrDefault(undefined, 21)).toBe(21)
expect(parseAsIntOrDefault('', 21)).toBe(21)
expect(parseAsIntOrDefault(null, 21)).toBe(21)
expect(parseAsIntOrDefault(String(Number.NaN), 21)).toBe(21)
expect(parseAsIntOrDefault(Number.MAX_SAFE_INTEGER + 1, 21)).toBe(21)
expect(parseAsIntOrDefault(Number.MIN_SAFE_INTEGER - 1, 21)).toBe(21)
})
})

View file

@ -1,5 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- okay when trying to parse arbitrary value on purpose
export const parseAsIntOrDefault = (value: any, defaultValue: number) => {
const parsed = Number.parseInt(`${value ?? defaultValue}`, 10)
export const parseAsIntOrDefault = (value: any, defaultValue: number): number => {
const parsed = Number.parseInt(`${value ?? Number.NaN}`, 10)
return Number.isSafeInteger(parsed) ? parsed : defaultValue
}

View file

@ -20,12 +20,20 @@ export interface PaymentAttempt {
export interface TakerInfo {
currentPaymentAttempt?: PaymentAttempt
running: boolean
scheduler: {
running: boolean
}
}
export interface MakerInfo {
running: boolean
}
interface JamSessionInfoContextType {
blockHeight?: number
takerInfo: TakerInfo
rescanInfo: RescanInfo
makerInfo: MakerInfo
setRescanInfo: Dispatch<SetStateAction<RescanInfo>>
setCurrentPaymentAttempt: (val: PaymentAttempt) => void
clearCurrentPaymentAttempt: () => void

View file

@ -10,7 +10,7 @@ import { withQueryDelay } from '@/lib/queryClient'
import { factorToPercentage, type WalletFileName } from '@/lib/utils'
import { jmSessionStore } from '@/store/jmSessionStore'
import { JamSessionInfoContext } from './JamSessionInfoContext'
import type { PaymentAttempt, RescanInfo, TakerInfo } from './JamSessionInfoContext'
import type { MakerInfo, PaymentAttempt, RescanInfo, TakerInfo } from './JamSessionInfoContext'
interface PaymentAttemptStoreState {
state?: PaymentAttempt
@ -57,11 +57,22 @@ export const JamSessionInfoContextProvider = ({
const isWalletPayment = currentPaymentAttempt?.walletFileName === walletFileName
const isCoinJoin = currentPaymentAttempt?.data.isCoinJoin === true
const takerIsRunning = state?.coinjoin_in_process === true
const schedulerIsRunning = takerIsRunning && !!state?.schedule
return {
currentPaymentAttempt: isWalletPayment && isCoinJoin ? currentPaymentAttempt : undefined,
running: takerIsRunning,
scheduler: {
running: schedulerIsRunning,
},
}
}, [state?.coinjoin_in_process, walletFileName, currentPaymentAttempt])
}, [state?.coinjoin_in_process, state?.schedule, walletFileName, currentPaymentAttempt])
const makerInfo = useMemo<MakerInfo>(() => {
const makerIsRunning = state?.maker_running === true
return {
running: makerIsRunning,
}
}, [state?.maker_running])
const getrescaninfoQueryOptions = useMemo(
() =>
@ -105,6 +116,7 @@ export const JamSessionInfoContextProvider = ({
blockHeight: state?.block_height ?? undefined,
takerRunning: state?.coinjoin_in_process === true,
takerInfo,
makerInfo,
rescanInfo,
setRescanInfo,
setCurrentPaymentAttempt,

View file

@ -32,7 +32,8 @@ export type FidelityBondSummary = {
export type WalletBalanceSummary = BalanceSummary
export type AddressStaticStatus = 'new' | 'used' | 'reused' | 'cj-out' | 'change-out' | 'non-cj-change' | 'deposit'
export type AddressStaticStatus =
'new' | 'reserved' | 'used' | 'reused' | 'cj-out' | 'change-out' | 'non-cj-change' | 'deposit'
type AddressStatusDynamic = string

View file

@ -68,7 +68,7 @@ const toAddressSummary = (accountSummary: AccountSummary): AddressSummary => {
const meta: AddressMeta = {
jarIndex: account.jarIndex,
address: entry.address,
used: entry.status !== 'new',
used: entry.status !== 'new' && entry.status !== 'reserved',
status: entry.status,
info: {
bech32: info.bech32,

View file

@ -493,8 +493,8 @@
"scheduler": {
"title": "Geplanter Sweep (experimentell)",
"subtitle": "Führe mehrere Transaktionen mit zufälligen Beträgen und Zeitintervallen durch, um deine Privatsphäre und die von anderen zu erhöhen. Jede durchgeführte Transaktion ist ein Coinjoin. ",
"error_starting_schedule_failed": "Der Scheduler konnte nicht gestartet werden. ",
"error_stopping_schedule_failed": "Der Scheduler konnte nicht gestoppt werden. ",
"error_starting_schedule_failed": "Der Scheduler konnte nicht gestartet werden. Begründung: {{ reason }}",
"error_stopping_schedule_failed": "Der Scheduler konnte nicht gestoppt werden. Begründung: {{ reason }}",
"complete_wallet_title": "Gesamte Wallet ",
"complete_wallet_subtitle": "Das wird alle deine nicht eingefrorenen Gelder verwenden. ",
"description_destination_addresses": "Der Scheduler wird alle verfügbaren Gelder in zufällige Beträge aufteilen und sie an mehrere Adressen schicken. ",
@ -508,7 +508,7 @@
"progress_tldr_seconds": "<1>{{ length }}</1> Transaktionen in <3>{{ seconds }}</3> Sekunden geplant.",
"progress_tldr_hours": "<1>{{ length }}</1> Transaktionen in <3>{{ hours }}</3> Stunden geplant.",
"progress_description": "Die Schätzung ist als minimale Wartezeit zu verstehen. Verzögerungen aufgrund von Netzwerkfehlern oder Transaktionsbestätigungen sind nicht eingerechnet. ",
"progress_done": "Alle Transaktionen erfolgreich durchgeführt. Der Scheduler wird in Kürze stoppen. ",
"progress_done_awaiting_stop": "Alle Transaktionen erfolgreich durchgeführt. Der Scheduler wird in Kürze stoppen. ",
"precondition": {
"hint_missing_utxos": "Um den Scheduler zu starten, brauchst du UTXOs mit {{ minConfirmations }} oder mehr Bestätigungen. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "Um den Scheduler zu starten, brauchen deine UTXOs {{ minConfirmations }} oder mehr Bestätigungen. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",

View file

@ -30,6 +30,9 @@
}
}
},
"duration_seconds": "{{ seconds }}s",
"duration_minutes": "{{ minutes }}m",
"duration_minutes_seconds": "{{ minutes }}m {{ seconds }}s",
"errors": {
"error_loading_wallet_failed": "Loading wallet failed. {{ reason }}",
"error_reloading_wallet_failed": "Reloading wallet failed. {{ reason }}",
@ -769,48 +772,46 @@
"scheduler": {
"title": "Scheduled Sweep (Experimental)",
"subtitle": "Execute multiple transactions using random amounts and time intervals to increase the privacy of yourself and others. Every scheduled transaction is a collaborative transaction.",
"error_starting_schedule_failed": "Starting the scheduler failed.",
"error_stopping_schedule_failed": "Stopping the scheduler failed.",
"error_starting_schedule_failed": "Starting a Scheduled Sweep failed. Reason: {{ reason }}",
"error_stopping_schedule_failed": "Stopping Scheduled Sweep failed. Reason: {{ reason }}",
"error_planning_schedule_failed": "Error while planning the Scheduled Sweep. Reason: {{ reason }}",
"complete_wallet_title": "Complete Wallet",
"complete_wallet_subtitle": "This will use all of your non-frozen funds.",
"description_destination_addresses": "The scheduler will send all available funds to multiple destinations, splitting them up in random chunks.",
"description_destination_addresses": "A Scheduled Sweep will send all available funds to multiple destinations, splitting them up in random chunks.",
"feedback_invalid_destination_address": "Please enter a valid destination address.",
"feedback_reused_destination_address": "This address is already used. To preserve your privacy please choose another one.",
"label_destination_input": "Destination {{ destination }}",
"placeholder_destination_input": "Enter destination address...",
"description_fees": "The scheduler will pay all occurring fees automatically.",
"button_start": "Start Scheduler",
"button_stop": "Stop Scheduler",
"progress_tldr_seconds": "Scheduled <1>{{ length }}</1> transactions over <3>{{ seconds }}</3> seconds.",
"progress_tldr_hours": "Scheduled <1>{{ length }}</1> transactions over <3>{{ hours }}</3> hours.",
"description_fees": "A Scheduled Sweep will pay all occurring fees automatically.",
"scheduler_options": "Scheduled Sweep options",
"button_start": "Start",
"button_stop": "Stop",
"button_plan": "Preview",
"button_plan_renew": "Renew",
"alert_scheduler_starting_title": "Starting Scheduled Sweep...",
"alert_scheduler_stopping_title": "Stopping Scheduled Sweep...",
"progress_tldr_seconds": "Scheduled <1>{{ length }}</1> actions over <3>{{ seconds }}</3> seconds.",
"progress_tldr_hours": "Scheduled <1>{{ length }}</1> actions over <3>{{ hours }}</3> hours.",
"progress_description": "This estimate is the minimum waiting time. Additional delays due to network communication or transaction confirmation are not considered.",
"progress_current_state_wait_before_next": "Waiting {{ wait }} before creating and broadcasting transaction <1>{{ current }}</1> of <3>{{ total }}</3>.",
"progress_current_state_waiting_confirmation": "Waiting for transaction <1>{{ current }}</1> of <3>{{ total }}</3> ({{ txid }}) to be confirmed.",
"progress_current_state_transaction_confirmed": "Transaction <1>{{ current }}</1> of <3>{{ total }}</3> confirmed.",
"progress_current_state_creating_next": "Creating and broadcasting transaction <1>{{ current }}</1> of <3>{{ total }}</3>...",
"progress_wait_seconds": "{{ seconds }}s",
"progress_wait_minutes": "{{ minutes }}m",
"progress_wait_minutes_seconds": "{{ minutes }}m {{ seconds }}s",
"progress_schedule_info_title": "Schedule details",
"progress_entry_label": "Transaction {{ index }}",
"progress_entry_state_pending": "Pending",
"progress_entry_state_waiting_confirmation": "Waiting for confirmation ({{ txid }})",
"progress_entry_state_confirmed": "Confirmed",
"progress_entry_wait_before_next": "Wait before next: {{ wait }}",
"progress_entry_wait_final": "Final transaction",
"progress_done": "All transactions completed successfully. The scheduler will stop soon.",
"progress_current_state_waiting_confirmation_title": "Waiting for action <1>{{ current }}</1> of <3>{{ total }}</3> to progress...",
"progress_current_state_waiting_confirmation_description": "Transaction (<1>{{ txid }}</1>) needs to reach a minimum number of confirmations before continuing.",
"progress_current_state_waiting_start_title": "Waiting for action <1>{{ current }}</1> of <3>{{ total }}</3> to start...",
"progress_current_state_executing_title": "Executing action <1>{{ current }}</1> of <3>{{ total }}</3>...",
"progress_entry_label": "Action {{ index }}",
"progress_entry_wait_before_next_title": "Wait period before next action",
"progress_done_awaiting_stop": "All actions completed successfully. The Scheduled Sweep will stop soon.",
"precondition": {
"hint_missing_utxos": "To run the scheduler you need UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "The scheduler requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
"hint_missing_utxos": "A Scheduled Sweep needs UTXOs with {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "A Scheduled Sweep requires your UTXOs to have {{ minConfirmations }} or more confirmations. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",
"hint_missing_retries": "Failed at sourcing commitments too many times. For security reasons, you need a fresh UTXO in the jar to try again. See <1>the docs</1> for more information.",
"nested_hint_wait_for_block": "Wait for one more block.",
"nested_hint_wait_for_block_other": "Wait for {{ count }} more blocks.",
"nested_hint_fund_wallet": "Fund your wallet and wait for one more block.",
"nested_hint_fund_wallet_other": "Fund your wallet and wait for {{ count }} more blocks."
},
"confirm_modal": {
"confirm_start_modal": {
"title": "Start Scheduled Sweep",
"body": "Are you sure you want to start a scheduled sweep?"
"text_body": "Are you sure you want to start a Scheduled Sweep?"
}
},
"modal": {

View file

@ -452,8 +452,8 @@
"scheduler": {
"title": "Spazzamento programmata (sperimentale)",
"subtitle": "Eseguire transazioni multiple utilizzando importi e intervalli di tempo casuali per aumentare la propria privacy e quella altrui. Ogni transazione programmata è una transazione collaborativa.",
"error_starting_schedule_failed": "L'avvio dello scheduler non è riuscito.",
"error_stopping_schedule_failed": "L'arresto dello scheduler non è riuscito.",
"error_starting_schedule_failed": "L'avvio dello scheduler non è riuscito. Motivo: {{ reason }}",
"error_stopping_schedule_failed": "L'arresto dello scheduler non è riuscito. Motivo: {{ reason }}",
"complete_wallet_title": "Completa portafoglio",
"complete_wallet_subtitle": "In questo modo si utilizzeranno tutti i fondi non congelati.",
"description_destination_addresses": "Lo scheduler invierà tutti i fondi disponibili a più destinazioni, suddividendoli in tranche casuali.",
@ -467,7 +467,7 @@
"progress_tldr_seconds": "Programmate <1>{{ length }}</1> transazioni su <3>{{ seconds }}</3> secondi.",
"progress_tldr_hours": "Schedulate <1>{{ length }}</1> transazioni su <3>{{ hours }}</3> ore",
"progress_description": "Questa stima rappresenta il tempo di attesa minimo. Non sono considerati i ritardi aggiuntivi dovuti alla comunicazione di rete o alla conferma della transazione.",
"progress_done": "Tutte le transazioni sono state completate con successo. Lo scheduler si fermerà a breve.",
"progress_done_awaiting_stop": "Tutte le transazioni sono state completate con successo. Lo scheduler si fermerà a breve.",
"precondition": {
"hint_missing_utxos": "Per eseguire lo scheduler sono necessari UTXO con {{ minConfirmations }} o più conferme. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "Lo scheduler richiede che gli UTXO abbiano {{ minConfirmations }} o più conferme. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",

View file

@ -452,8 +452,8 @@
"scheduler": {
"title": "Programar Limpeza (Experimental)",
"subtitle": "Execute várias transações usando valores aleatórios e intervalos de tempo para aumentar a sua privacidade e a dos outros. Cada transação agendada é uma transação colaborativa.",
"error_starting_schedule_failed": "Falha ao iniciar o agendador.",
"error_stopping_schedule_failed": "Falha ao parar agendador.",
"error_starting_schedule_failed": "Falha ao iniciar o agendador. Motivo: {{ reason }}",
"error_stopping_schedule_failed": "Falha ao parar agendador. Motivo: {{ reason }}",
"complete_wallet_title": "Carteira Completa",
"complete_wallet_subtitle": "Isso usará todos os seus fundos não congelados.",
"description_destination_addresses": "O agendador enviará todos os fundos disponíveis para vários destinos, dividindo-os em partes aleatórias.",
@ -467,7 +467,7 @@
"progress_tldr_seconds": "<1>{{ length }}</1> transações agendadas em <3>{{ seconds }}</3> segundos.",
"progress_tldr_hours": "<1>{{ length }}</1> transações agendadas em <3>{{ hours }}</3> horas.",
"progress_description": "Esta estimativa é o tempo mínimo de espera. Atrasos adicionais devido a comunicação de rede ou confirmação de transação não são considerados.",
"progress_done": "Todas as transações foram concluídas com sucesso. O agendador irá parar em breve.",
"progress_done_awaiting_stop": "Todas as transações foram concluídas com sucesso. O agendador irá parar em breve.",
"precondition": {
"hint_missing_utxos": "Para executar o agendador você precisa de UTXOs com {{ minConfirmations }} ou mais confirmações. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "O agendador exige que seus UTXOs tenham {{ minConfirmations }} ou mais confirmações. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",

View file

@ -544,8 +544,8 @@
"scheduler": {
"title": "Плановый Перенос средств (экспериментальная фича)",
"subtitle": "Выполняйте несколько Совместных Транзакций для вывода всех доступных монет, используя случайные суммы и временные интервалы, чтобы повысить конфиденциальность себя и других. Каждая запланированная транзакция Переноса является Совместной Транзакцией.",
"error_starting_schedule_failed": "Не удалось запустить планировщик Переноса.",
"error_stopping_schedule_failed": "Не удалось остановить планировщик Переноса.",
"error_starting_schedule_failed": "Не удалось запустить планировщик Переноса. Причина: {{ reason }}",
"error_stopping_schedule_failed": "Не удалось остановить планировщик Переноса. Причина: {{ reason }}",
"complete_wallet_title": "Весь кошелек",
"complete_wallet_subtitle": "При этом будут использованы все ваши незамороженные средства.",
"description_destination_addresses": "Планировщик Переноса отправит все доступные средства в несколько пунктов назначения, разделив их на случайные части.",
@ -559,7 +559,7 @@
"progress_tldr_seconds": "Запланировано <1>{{ length }}</1> Совместных Транзакций Переноса в течение <3>{{ seconds }}</3> секунд.",
"progress_tldr_hours": "Запланировано <1>{{ length }}</1> Совместных Транзакций Переноса в течение <3>{{ hours }}</3> часов.",
"progress_description": "Эта оценка является минимальным временем ожидания. Дополнительные задержки из-за сетевой коммуникации или подтверждения транзакции не учитываются.",
"progress_done": "Все Совместные Транзакции Переноса завершены успешно. Планировщик скоро остановится.",
"progress_done_awaiting_stop": "Все Совместные Транзакции Переноса завершены успешно. Планировщик скоро остановится.",
"precondition": {
"hint_missing_utxos": "Для запуска планировщика Переноса вам понадобятся UTXO с {{ minConfirmations }} или большим количеством подтверждений. $t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "Планировщик требует, чтобы ваши UTXO имели {{ minConfirmations }} или более подтверждений. $t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",

View file

@ -493,8 +493,8 @@
"scheduler": {
"title": "预定扫空(试验性)",
"subtitle": "使用随机金额和时间间隔执行多次交易,以此增加你和他人的隐私程度。每个预定的交易都是协作式交易。",
"error_starting_schedule_failed": "开始调度程序失败。",
"error_stopping_schedule_failed": "停止调度程序失败。",
"error_starting_schedule_failed": "开始调度程序失败。原因:{{ reason }}",
"error_stopping_schedule_failed": "停止调度程序失败。原因:{{ reason }}",
"complete_wallet_title": "整个钱包",
"complete_wallet_subtitle": "这将使用你所有解冻的资金。",
"description_destination_addresses": "调度程序将把所有可用资金随机分配并发送到多个目的地。",
@ -508,7 +508,7 @@
"progress_tldr_seconds": "在<3>{{ seconds }}</3>秒的范围内预定了<1>{{ length }}</1>个交易。",
"progress_tldr_hours": "在<3>{{ hours }}</3>小时的范围内预定了<1>{{ length }}</1>个交易。",
"progress_description": "此估计是最低等待时间。不计因网络通信或交易确认而造成的其他耽延。",
"progress_done": "所有交易都已成功完成。调度程序即将停止。",
"progress_done_awaiting_stop": "所有交易都已成功完成。调度程序即将停止。",
"precondition": {
"hint_missing_utxos": "要运行调度程序你需要具有至少{{ minConfirmations }}次确认的 UTXOs。$t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "调度程序要求你的 UTXOs 具有至少{{ minConfirmations }}次确认。$t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",

View file

@ -493,8 +493,8 @@
"scheduler": {
"title": "預定掃空(試驗性)",
"subtitle": "使用隨機金額和時間間隔執行多次交易,以此增加你和他人的隱私程度。每個預定的交易都是協作式交易。",
"error_starting_schedule_failed": "開始調度程序失敗。",
"error_stopping_schedule_failed": "停止調度程序失敗。",
"error_starting_schedule_failed": "開始調度程序失敗。原因: {{ reason }}",
"error_stopping_schedule_failed": "停止調度程序失敗。原因: {{ reason }}",
"complete_wallet_title": "整個錢包",
"complete_wallet_subtitle": "這將使用你所有解凍的資金。",
"description_destination_addresses": "調度程序將把所有可用資金隨機分配並發送到多個目的地。",
@ -508,7 +508,7 @@
"progress_tldr_seconds": "在<3>{{ seconds }}</3>的範圍內預定了<1>{{ length }}</1>個交易。",
"progress_tldr_hours": "在<3>{{ hours }}</3>小時的範圍內預定了<1>{{ length }}</1>個交易。",
"progress_description": "此估計是最低等待時間。不計因網路通信或交易確認而造成的其他耽延。",
"progress_done": "所有交易都已成功完成。調度程序即將停止。",
"progress_done_awaiting_stop": "所有交易都已成功完成。調度程序即將停止。",
"precondition": {
"hint_missing_utxos": "要運行調度程序你需要具有至少{{ minConfirmations }}次確認的 UTXOs。$t(scheduler.precondition.nested_hint_fund_wallet, {\"count\": {{ minConfirmations }} })",
"hint_missing_confirmations": "調度程序要求你的 UTXOs 具有至少{{ minConfirmations }}次確認。$t(scheduler.precondition.nested_hint_wait_for_block, {\"count\": {{ amountOfMissingConfirmations }} })",

View file

@ -1,7 +1,7 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import { JM_WALLET_FILE_EXTENSION, type OfferType } from '@/constants/jm'
import type { BlockHeight, Factor, Milliseconds, MnemonicPhrase } from '@/types/global'
import type { BlockHeight, Factor, Milliseconds, Minutes, MnemonicPhrase, Seconds } from '@/types/global'
const HORIZONTAL_ELLIPSIS = '\u2026' // Horizontal Ellipsis `…`
@ -138,8 +138,11 @@ export const tryBtcToSat = (value: string): number | undefined => {
}
export const SEGWIT_ACTIVATION_BLOCK: BlockHeight = 481_824 // https://github.com/bitcoin/bitcoin/blob/v25.0/src/kernel/chainparams.cpp#L86
export const AVERAGE_BLOCKS_PER_DAY: number = Math.round((1 / 10) * 60 * 24)
export const AVERAGE_BLOCKS_PER_HOUR: number = Math.round((1 / 10) * 60)
export const AVERAGE_BLOCKS_PER_DAY: number = AVERAGE_BLOCKS_PER_HOUR * 24
export const AVERAGE_BLOCKS_PER_YEAR: number = 365 * AVERAGE_BLOCKS_PER_DAY
export const MEAN_DURATION_BETWEEN_BLOCKS_MINUTES: Minutes = 60 / AVERAGE_BLOCKS_PER_HOUR
export const MEAN_DURATION_BETWEEN_BLOCKS_SECONDS: Seconds = MEAN_DURATION_BETWEEN_BLOCKS_MINUTES * 60
// if applicable, the genesis date can be used as minimum `since` timestamp
export const BITCOIN_GENESIS_DATE = new Date('2009-01-03T18:15:05Z')
@ -313,13 +316,13 @@ export const time = (() => {
from?: Milliseconds
to: Milliseconds
locale: string
}) => humanReadableTimeInterval(timeInterval({ from, to }), locale || 'en')
}) => humanReadableRelativeTimeInterval(timeInterval({ from, to }), locale || 'en')
const timeInterval = ({ from = Date.now(), to }: { from?: Milliseconds; to: Milliseconds }): TimeInterval => {
return to - from
}
const humanReadableTimeInterval = (timeInterval: TimeInterval, locale: string) => {
const humanReadableRelativeTimeInterval = (timeInterval: TimeInterval, locale: string) => {
const rtf = new Intl.RelativeTimeFormat(locale || 'en', { numeric: 'always', style: 'long' })
const sortedUnits = (Object.keys(UNIT_MILLIS) as Unit[])
@ -340,5 +343,6 @@ export const time = (() => {
return {
timeInterval,
humanReadableDuration,
humanReadableRelativeTimeInterval,
}
})()

View file

@ -1,79 +1,179 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '@/components/ui/button'
import { Mail, Plus, PlusCircle, Search, UserPlus } from 'lucide-react'
import { expect, userEvent, within } from 'storybook/test'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
const meta: Meta<typeof DropdownMenu> = {
/**
* Displays a menu to the user such as a set of actions or functions
* triggered by a button.
*/
const meta = {
title: 'Core/DropdownMenu',
component: DropdownMenu,
tags: ['autodocs'],
}
argTypes: {},
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Billing</DropdownMenuItem>
<DropdownMenuItem>Team</DropdownMenuItem>
<DropdownMenuItem>Subscription</DropdownMenuItem>
<DropdownMenuItem variant="destructive">Delete wallet</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof DropdownMenu>
export default meta
type Story = StoryObj<typeof DropdownMenu>
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => (
<DropdownMenu defaultOpen>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Wallet actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Rename wallet</DropdownMenuItem>
/**
* The default form of the dropdown menu.
*/
export const Default: Story = {}
/**
* A dropdown menu with shortcuts.
*/
export const WithShortcuts: Story = {
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
<DropdownMenuLabel>Controls</DropdownMenuLabel>
<DropdownMenuItem>
Export backup
<DropdownMenuShortcut>B</DropdownMenuShortcut>
Back
<DropdownMenuShortcut>[</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem disabled>
Forward
<DropdownMenuShortcut>]</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem variant="destructive">Delete wallet</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithSelectionItems: Story = {
render: () => (
<DropdownMenu defaultOpen>
<DropdownMenuTrigger asChild>
<Button variant="outline">Display options</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem checked>Show balances</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Show frozen coins</DropdownMenuCheckboxItem>
/**
* A dropdown menu with submenus.
*/
export const WithSubmenus: Story = {
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
<DropdownMenuItem>
<Search className="mr-2 size-4" />
<span>Search</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value="btc">
<DropdownMenuRadioItem value="btc">BTC</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="sats">Sats</DropdownMenuRadioItem>
<DropdownMenuGroup>
<DropdownMenuItem>
<Plus className="mr-2 size-4" />
<span>New Team</span>
<DropdownMenuShortcut>+T</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserPlus className="mr-2 size-4" />
<span>Invite users</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>
<Mail className="mr-2 size-4" />
<span>Email</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PlusCircle className="mr-2 size-4" />
<span>More...</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
),
}
/**
* A dropdown menu with radio items.
*/
export const WithRadioItems: Story = {
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
<DropdownMenuLabel inset>Status</DropdownMenuLabel>
<DropdownMenuRadioGroup value="warning">
<DropdownMenuRadioItem value="info">Info</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="warning">Warning</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="error">Error</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
),
}
export const WithDisabledItem: Story = {
render: () => (
<DropdownMenu defaultOpen>
<DropdownMenuTrigger asChild>
<Button variant="outline">Wallet actions</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Wallet actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>Rename wallet</DropdownMenuItem>
<DropdownMenuItem disabled>Export backup</DropdownMenuItem>
<DropdownMenuItem variant="destructive">Delete wallet</DropdownMenuItem>
/**
* A dropdown menu with checkboxes.
*/
export const WithCheckboxes: Story = {
render: (args) => (
<DropdownMenu {...args}>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
<DropdownMenuCheckboxItem checked>
Autosave
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem>Show Comments</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
),
}
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 })
})
},
}

View file

@ -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<typeof Field> = {
title: 'Core/Field',
component: Field,
tags: ['autodocs'],
}
argTypes: {
orientation: {
control: 'select',
options: ['vertical', 'horizontal', 'responsive'],
},
},
parameters: {
layout: 'centered',
},
args: {
orientation: 'vertical',
},
decorators: (Story) => (
<div className="w-full max-w-md min-w-sm">
<Story />
</div>
),
} satisfies Meta<typeof Field>
export default meta
type Story = StoryObj<typeof Field>
export const Default: Story = {
render: () => (
<FieldGroup className="max-w-sm">
<Field>
<FieldLabel htmlFor="wallet-name">Wallet name</FieldLabel>
<Input id="wallet-name" placeholder="Enter wallet name" />
<FieldDescription>This name is only used locally in Jam.</FieldDescription>
</Field>
</FieldGroup>
),
}
export const Horizontal: Story = {
render: () => (
<Field orientation="horizontal" className="max-w-sm">
<Checkbox id="remember-wallet" />
<FieldContent>
<FieldLabel htmlFor="remember-wallet">Remember wallet</FieldLabel>
<FieldDescription>Keep this wallet available in the wallet list.</FieldDescription>
</FieldContent>
</Field>
),
}
export const WithError: Story = {
render: () => (
<FieldGroup className="max-w-sm">
<Field data-invalid="true">
<FieldLabel htmlFor="wallet-password">Wallet password</FieldLabel>
<Input id="wallet-password" type="password" aria-invalid="true" />
<FieldError>Password is required.</FieldError>
</Field>
</FieldGroup>
),
}
export const Fieldset: Story = {
render: () => (
<FieldSet className="max-w-sm">
<FieldLegend>Fee settings</FieldLegend>
/**
* Field with Input component for text input.
*/
export const WithInput: Story = {
render: (args) => (
<FieldSet>
<FieldGroup>
<Field>
<FieldLabel htmlFor="min-fee">Minimum fee</FieldLabel>
<Input id="min-fee" defaultValue="21" />
</Field>
<FieldSeparator>or</FieldSeparator>
<Field>
<FieldTitle>Use default fee</FieldTitle>
<FieldDescription>Let Jam choose the default fee for this wallet.</FieldDescription>
<Field {...args}>
<FieldLabel htmlFor="username">Username</FieldLabel>
<Input id="username" type="text" placeholder="Satoshi" />
<FieldDescription>Choose a unique username for your account.</FieldDescription>
</Field>
</FieldGroup>
</FieldSet>
),
}
export const WithError: Story = {
render: () => (
<FieldSet>
<FieldGroup>
<Field data-invalid="true">
<FieldLabel htmlFor="wallet-password">Wallet password</FieldLabel>
<Input id="wallet-password" type="password" aria-invalid="true" />
<FieldError>Password is required.</FieldError>
</Field>
</FieldGroup>
</FieldSet>
),
}
export const WithErrors: Story = {
render: () => (
<FieldSet>
<FieldGroup>
<Field data-invalid="true">
<FieldLabel htmlFor="wallet-password">Wallet password</FieldLabel>
<Input id="wallet-password" type="password" aria-invalid="true" />
<FieldError />
<FieldError />
<FieldError />
<FieldError errors={[{ message: 'Password must be present' }]} />
<FieldError
errors={[
{
message: 'Password must longer than 32 chars',
},
{
message: 'Password must shorter than 128 chars',
},
]}
/>
</Field>
</FieldGroup>
</FieldSet>
),
}
/**
* Field with textarea for longer text input.
*/
export const WithTextarea: Story = {
render: (args) => (
<FieldSet>
<FieldGroup>
<Field {...args}>
<FieldLabel htmlFor="feedback">Feedback</FieldLabel>
<Textarea id="feedback" placeholder="Your feedback helps us improve..." rows={4} />
<FieldDescription>Share your thoughts about our service.</FieldDescription>
</Field>
</FieldGroup>
</FieldSet>
),
}
/**
* Field with Select component for dropdown selections.
*/
export const WithSelect: Story = {
render: (args) => (
<Field {...args}>
<FieldLabel>Department</FieldLabel>
<Select>
<SelectTrigger>
<SelectValue placeholder="Choose department" />
</SelectTrigger>
<SelectContent>
<SelectItem value="engineering">Engineering</SelectItem>
<SelectItem value="design">Design</SelectItem>
<SelectItem value="marketing">Marketing</SelectItem>
<SelectItem value="sales">Sales</SelectItem>
<SelectItem value="support">Customer Support</SelectItem>
<SelectItem value="hr">Human Resources</SelectItem>
<SelectItem value="finance">Finance</SelectItem>
<SelectItem value="operations">Operations</SelectItem>
</SelectContent>
</Select>
<FieldDescription>Select your department or area of work.</FieldDescription>
</Field>
),
}
/**
* Field with Slider component and dynamic value display.
*/
export const WithSlider: Story = {
render: () => {
const [value, setValue] = React.useState([200, 800])
return (
<Field>
<FieldTitle>Price Range</FieldTitle>
<FieldDescription>
Set your budget range ($
<span className="font-medium tabular-nums">{value[0]}</span> -{' '}
<span className="font-medium tabular-nums">{value[1]}</span>).
</FieldDescription>
<Slider
value={value}
onValueChange={setValue}
max={1_000}
min={0}
step={10}
className="mt-2 w-full"
aria-label="Price Range"
/>
</Field>
)
},
}
/**
* FieldSet with multiple related fields in a grid layout.
*/
export const WithFieldset: Story = {
render: () => (
<div className="w-full max-w-md space-y-6">
<FieldSet>
<FieldLegend>Address Information</FieldLegend>
<FieldDescription>We need your address to deliver your order.</FieldDescription>
<FieldGroup>
<Field>
<FieldLabel htmlFor="street">Home Address</FieldLabel>
<Input id="street" type="text" placeholder="123 Home St" />
</Field>
<FieldSeparator>or</FieldSeparator>
<Field>
<FieldLabel htmlFor="company">Company Address</FieldLabel>
<Input id="company" type="text" placeholder="456 Main St" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="city">City</FieldLabel>
<Input id="city" type="text" placeholder="New York" />
</Field>
<Field>
<FieldLabel htmlFor="zip">Postal Code</FieldLabel>
<Input id="zip" type="text" placeholder="90502" />
</Field>
</div>
</FieldGroup>
</FieldSet>
</div>
),
}
/**
* Field with checkbox inputs using FieldSet structure.
*/
export const WithCheckbox: Story = {
render: () => (
<FieldGroup>
<FieldSet>
<FieldLegend variant="label">Show these items on the desktop</FieldLegend>
<FieldDescription>Select the items you want to show on the desktop.</FieldDescription>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox id="finder-pref-9k2-hard-disks-ljj" />
<FieldLabel htmlFor="finder-pref-9k2-hard-disks-ljj" className="font-normal" defaultChecked>
Hard disks
</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="finder-pref-9k2-external-disks-1yg" />
<FieldLabel htmlFor="finder-pref-9k2-external-disks-1yg" className="font-normal">
External disks
</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="finder-pref-9k2-cds-dvds-fzt" />
<FieldLabel htmlFor="finder-pref-9k2-cds-dvds-fzt" className="font-normal">
CDs, DVDs, and iPods
</FieldLabel>
</Field>
<Field orientation="horizontal">
<Checkbox id="finder-pref-9k2-connected-servers-6l2" />
<FieldLabel htmlFor="finder-pref-9k2-connected-servers-6l2" className="font-normal">
Connected servers
</FieldLabel>
</Field>
</FieldGroup>
</FieldSet>
<FieldSeparator />
<Field orientation="horizontal">
<Checkbox id="finder-pref-9k2-sync-folders-nep" defaultChecked />
<FieldContent>
<FieldLabel htmlFor="finder-pref-9k2-sync-folders-nep">Sync Desktop & Documents folders</FieldLabel>
<FieldDescription>
Your Desktop & Documents folders are being synced with iCloud Drive. You can access them from other devices.
</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
),
}
/**
* Field with RadioGroup for single selection.
*/
export const WithRadio: Story = {
render: () => (
<FieldSet>
<FieldLabel>Subscription Plan</FieldLabel>
<FieldDescription>Yearly and lifetime plans offer significant savings.</FieldDescription>
<RadioGroup defaultValue="monthly">
<Field orientation="horizontal">
<RadioGroupItem value="monthly" id="plan-monthly" />
<FieldLabel htmlFor="plan-monthly" className="font-normal">
Monthly ($9.99/month)
</FieldLabel>
</Field>
<Field orientation="horizontal">
<RadioGroupItem value="yearly" id="plan-yearly" />
<FieldLabel htmlFor="plan-yearly" className="font-normal">
Yearly ($99.99/year)
</FieldLabel>
</Field>
<Field orientation="horizontal">
<RadioGroupItem value="lifetime" id="plan-lifetime" />
<FieldLabel htmlFor="plan-lifetime" className="font-normal">
Lifetime ($299.99)
</FieldLabel>
</Field>
</RadioGroup>
</FieldSet>
),
}
/**
* Field with Switch in horizontal orientation.
*/
export const WithSwitch: Story = {
render: () => (
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor="2fa">Multi-factor authentication</FieldLabel>
<FieldDescription>
Enable multi-factor authentication. If you do not have a two-factor device, you can use a one-time code sent
to your email.
</FieldDescription>
</FieldContent>
<Switch id="2fa" />
</Field>
),
}
/**
* Selectable field groups with RadioItem for choice cards.
*/
export const ChoiceCard: Story = {
render: () => (
<FieldGroup>
<FieldSet>
<FieldLabel htmlFor="compute-environment-p8w">Compute Environment</FieldLabel>
<FieldDescription>Select the compute environment for your cluster.</FieldDescription>
<RadioGroup defaultValue="kubernetes">
<FieldLabel htmlFor="kubernetes-r2h">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Kubernetes</FieldTitle>
<FieldDescription>Run GPU workloads on a K8s configured cluster.</FieldDescription>
</FieldContent>
<RadioGroupItem value="kubernetes" id="kubernetes-r2h" />
</Field>
</FieldLabel>
<FieldLabel htmlFor="vm-z4k">
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>Virtual Machine</FieldTitle>
<FieldDescription>Access a VM configured cluster to run GPU workloads.</FieldDescription>
</FieldContent>
<RadioGroupItem value="vm" id="vm-z4k" />
</Field>
</FieldLabel>
</RadioGroup>
</FieldSet>
</FieldGroup>
),
}

View file

@ -0,0 +1,355 @@
import * as React from 'react'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { BadgeCheckIcon, ChevronDownIcon, ChevronRightIcon, Plus, PlusIcon, ShieldAlertIcon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@/components/ui/item'
/**
* A versatile component that you can use to display any content.
*/
const meta: Meta<typeof Item> = {
title: 'Core/Item',
component: Item,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['default', 'outline', 'muted'],
},
size: {
control: 'select',
options: ['default', 'sm'],
},
},
parameters: {
layout: 'centered',
},
args: {
variant: 'default',
size: 'default',
},
} satisfies Meta<typeof Item>
export default meta
type Story = StoryObj<typeof meta>
/**
* Basic item with title, description, and actions.
*/
export const Default: Story = {
render: (args) => (
<div className="flex w-full max-w-md flex-col gap-6">
<Item {...args} variant="outline">
<ItemContent>
<ItemTitle>Basic Item</ItemTitle>
<ItemDescription>A simple item with title and description.</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="outline" size="sm">
Action
</Button>
</ItemActions>
</Item>
<Item variant="outline" size="sm" asChild>
<a href="#">
<ItemMedia>
<BadgeCheckIcon className="size-5" />
</ItemMedia>
<ItemContent>
<ItemTitle>Your profile has been verified.</ItemTitle>
</ItemContent>
<ItemActions>
<ChevronRightIcon className="size-4" />
</ItemActions>
</a>
</Item>
</div>
),
}
/**
* Use the `outline` variant to add a visible border to the item.
*/
export const Outline: Story = {
render: (args) => (
<div className="flex w-full max-w-md flex-col gap-6">
<Item {...args} variant="outline">
<ItemMedia>
<Avatar className="size-10">
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
<AvatarFallback>JM</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>Software Update Available</ItemTitle>
<ItemDescription>Version 2.0 is now available for download.</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm" variant="outline">
Update
</Button>
</ItemActions>
</Item>
</div>
),
}
/**
* Use the `muted` variant to add a subtle background to the item.
*/
export const Muted: Story = {
render: (args) => (
<div className="flex w-full max-w-md flex-col gap-6">
<Item {...args} variant="muted">
<ItemMedia variant="icon">
<BadgeCheckIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Account Verified</ItemTitle>
<ItemDescription>Your account has been successfully verified.</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm" variant="ghost">
Dismiss
</Button>
</ItemActions>
</Item>
</div>
),
}
/**
* Use the `sm` size for a more compact item layout.
*/
export const Small: Story = {
render: (args) => (
<div className="flex w-full max-w-md flex-col gap-6">
<ItemGroup>
<Item {...args} variant="outline" size="sm">
<ItemMedia>
<Avatar className="size-8">
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
<AvatarFallback>JM</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>New message from Jam</ItemTitle>
<ItemDescription>Hey, how are you doing?</ItemDescription>
</ItemContent>
</Item>
<ItemSeparator />
<Item variant="outline" size="sm">
<ItemMedia>
<Avatar className="size-8">
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
<AvatarFallback>ML</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>New message from johndoe</ItemTitle>
<ItemDescription>Check out this new feature!</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</div>
),
}
/**
* Item with icon media element.
*/
export const WithIcon: Story = {
render: (args) => (
<div className="flex w-full max-w-lg flex-col gap-6">
<Item {...args} variant="outline">
<ItemMedia variant="icon">
<ShieldAlertIcon />
</ItemMedia>
<ItemContent>
<ItemTitle>Security Alert</ItemTitle>
<ItemDescription>New login detected from unknown device.</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm" variant="outline">
Review
</Button>
</ItemActions>
</Item>
</div>
),
}
/**
* Items with avatar media elements.
*/
export const WithAvatar: Story = {
render: (args) => (
<div className="flex w-full max-w-lg flex-col gap-6">
<Item {...args} variant="outline">
<ItemMedia>
<Avatar className="size-10">
<AvatarImage className="bg-white" src="/apple-touch-icon.png" />
<AvatarFallback>HR</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>HR</ItemTitle>
<ItemDescription>Last seen 5 months ago</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="icon-sm" variant="outline" className="rounded-full" aria-label="Invite">
<Plus />
</Button>
</ItemActions>
</Item>
<Item variant="outline">
<ItemMedia>
<div className="*:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:grayscale">
<Avatar className="hidden sm:flex">
<AvatarImage className="bg-white" src="/apple-touch-icon.png" alt="@Jam" />
<AvatarFallback>JM</AvatarFallback>
</Avatar>
<Avatar className="hidden sm:flex">
<AvatarImage className="bg-white" src="/apple-touch-icon.png" alt="@johndoe" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<Avatar>
<AvatarImage className="bg-white" src="/apple-touch-icon.png" alt="@hr" />
<AvatarFallback>HR</AvatarFallback>
</Avatar>
</div>
</ItemMedia>
<ItemContent>
<ItemTitle>No Team Members</ItemTitle>
<ItemDescription>Invite your team to collaborate on this project.</ItemDescription>
</ItemContent>
<ItemActions>
<Button size="sm" variant="outline">
Invite
</Button>
</ItemActions>
</Item>
</div>
),
}
/**
* Grouped items with separators.
*/
export const WithGroup: Story = {
render: (args) => {
const people = [
{
username: 'Jam',
avatar: '/apple-touch-icon.png',
email: 'Jam@example.org',
},
{
username: 'johndoe',
avatar: '/apple-touch-icon.png',
email: 'johndoe@example.org',
},
{
username: 'hr',
avatar: '/apple-touch-icon.png',
email: 'hr@example.org',
},
]
return (
<div className="flex w-full max-w-md flex-col gap-6">
<ItemGroup>
{people.map((person, index) => (
<React.Fragment key={person.username}>
<Item {...args}>
<ItemMedia>
<Avatar>
<AvatarImage className="bg-white grayscale" src={person.avatar} />
<AvatarFallback>{person.username.charAt(0)}</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent className="gap-1">
<ItemTitle>{person.username}</ItemTitle>
<ItemDescription>{person.email}</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="ghost" size="icon" className="rounded-full">
<PlusIcon />
</Button>
</ItemActions>
</Item>
{index !== people.length - 1 && <ItemSeparator />}
</React.Fragment>
))}
</ItemGroup>
</div>
)
},
}
/**
* Items in a dropdown menu.
*/
export const WithDropdown: Story = {
render: (args) => {
const people = [
{
username: 'Jam',
avatar: '/apple-touch-icon.png',
email: 'Jam@example.org',
},
{
username: 'johndoe',
avatar: '/apple-touch-icon.png',
email: 'johndoe@example.org',
},
{
username: 'hr',
avatar: '/apple-touch-icon.png',
email: 'hr@example.org',
},
]
return (
<div className="flex min-h-64 w-full max-w-md flex-col items-center gap-6">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-fit">
Select <ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-72 [--radius:0.65rem]" align="end">
{people.map((person) => (
<DropdownMenuItem key={person.username} className="p-0">
<Item {...args} size="sm" className="w-full p-2">
<ItemMedia>
<Avatar className="size-8">
<AvatarImage className="bg-white grayscale" src={person.avatar} />
<AvatarFallback>{person.username.charAt(0)}</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent className="gap-0.5">
<ItemTitle>{person.username}</ItemTitle>
<ItemDescription>{person.email}</ItemDescription>
</ItemContent>
</Item>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
}

View file

@ -0,0 +1,179 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Calendar, ChevronUp, Home, Inbox, Search, Settings, User2 } from 'lucide-react'
import { userEvent } from 'storybook/test'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/sidebar'
/**
* A composable, themeable and customizable sidebar component.
*/
const meta = {
title: 'Core/Sidebar',
component: Sidebar,
tags: ['autodocs'],
argTypes: {
side: {
options: ['left', 'right'],
control: { type: 'radio' },
},
variant: {
options: ['sidebar', 'floating', 'inset'],
control: { type: 'radio' },
},
collapsible: {
options: ['offcanvas', 'icon', 'none'],
control: { type: 'radio' },
},
},
args: {
side: 'left',
variant: 'sidebar',
collapsible: 'icon',
},
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
<SidebarProvider className="min-h-[380px]">
<Story />
<section className="m-4">
<SidebarTrigger />
<div className="size-full" />
</section>
</SidebarProvider>
),
],
} satisfies Meta<typeof Sidebar>
export default meta
type Story = StoryObj<typeof Sidebar>
// Menu items.
const items = [
{
title: 'Home',
url: '#',
icon: Home,
},
{
title: 'Inbox',
url: '#',
icon: Inbox,
},
{
title: 'Calendar',
url: '#',
icon: Calendar,
},
{
title: 'Search',
url: '#',
icon: Search,
},
{
title: 'Settings',
url: '#',
icon: Settings,
},
]
/**
* A simple sidebar with a group of menu items.
*/
export const Simple: Story = {
render: (args) => (
<Sidebar {...args}>
<SidebarHeader />
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
</Sidebar>
),
}
/**
* A simple sidebar with a footer menu item.
*/
export const Footer: Story = {
render: (args) => (
<Sidebar {...args}>
<SidebarHeader />
<SidebarContent />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<User2 /> Username
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="w-(--radix-popper-anchor-width)">
<DropdownMenuItem>
<span>Account</span>
</DropdownMenuItem>
<DropdownMenuItem>
<span>Billing</span>
</DropdownMenuItem>
<DropdownMenuItem>
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
),
}
export const ShouldCloseOpen: Story = {
...Simple,
name: 'when clicking the trigger, should close and open the sidebar',
tags: ['!dev', '!autodocs'],
play: async ({ canvas, step }) => {
const sidebarButton = await canvas.findByRole('button', {
name: /toggle/i,
})
await step('close the sidebar', async () => {
await userEvent.click(sidebarButton)
})
await step('reopen the sidebar', async () => {
await userEvent.click(sidebarButton)
})
},
}

View file

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Slider } from '@/components/ui/slider'
/**
* An input where the user selects a value from within a given range.
*/
const meta = {
title: 'Core/Slider',
component: Slider,
tags: ['autodocs'],
argTypes: {},
args: {
defaultValue: [33],
max: 100,
step: 1,
minStepsBetweenThumbs: 0,
},
} satisfies Meta<typeof Slider>
export default meta
type Story = StoryObj<typeof meta>
/**
* The default form of the slider.
*/
export const Default: Story = {}
/**
* Use the `orientation` prop to render a vertical slider.
*/
export const Vertical: Story = {
args: {
orientation: 'vertical',
className: 'h-40',
},
}
/**
* Use the `inverted` prop to have the slider fill from right to left.
*/
export const Inverted: Story = {
args: {
inverted: true,
},
}
/**
* Use the `disabled` prop to disable the slider.
*/
export const Disabled: Story = {
args: {
disabled: true,
},
}
/**
* Use the `disabled` prop to disable the slider.
*/
export const Multiple: Story = {
args: {
defaultValue: [33, 66],
},
}

View file

@ -0,0 +1,147 @@
import type { TumblerPhaseResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { Meta, StoryObj } from '@storybook/react-vite'
import { ScheduleEntryItem } from '@/components/sweep/ScheduleEntryItem'
import { toScheduleEntry } from '@/components/sweep/scheduleUtils'
const meta: Meta<typeof ScheduleEntryItem> = {
title: 'Jam/ScheduleEntryItem',
component: ScheduleEntryItem,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof ScheduleEntryItem>
const takerPhase: TumblerPhaseResponse = {
kind: 'taker_coinjoin',
index: 0,
status: 'running',
wait_seconds: 20.8818214051562,
started_at: '2026-07-19T10:35:52.775747+00:00',
finished_at: null,
error: null,
mixdepth: 0,
amount: 0,
amount_fraction: null,
counterparty_count: 21,
destination: 'INTERNAL',
txid: null,
duration_seconds: null,
target_cj_count: null,
idle_timeout_seconds: null,
cj_served: null,
attempt_count: 0,
}
const makerPhase: TumblerPhaseResponse = {
kind: 'maker_session',
index: 4,
status: 'pending',
wait_seconds: 1.5946546346591093,
started_at: null,
finished_at: null,
error: null,
mixdepth: null,
amount: null,
amount_fraction: null,
counterparty_count: null,
destination: null,
txid: null,
duration_seconds: 43200,
target_cj_count: null,
idle_timeout_seconds: 60,
cj_served: 0,
attempt_count: 0,
}
export const Active: Story = {
args: {
value: toScheduleEntry(takerPhase, []),
active: true,
},
}
export const Running: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
status: 'running',
},
[],
),
active: false,
},
}
export const Pending: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
status: 'pending',
},
[],
),
active: false,
},
}
export const Completed: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
status: 'completed',
},
[],
),
active: false,
},
}
export const Skipped: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
status: 'skipped',
},
[],
),
active: false,
},
}
export const Cancelled: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
status: 'cancelled',
},
[],
),
active: false,
},
}
export const Failed: Story = {
args: {
value: toScheduleEntry(
{
...takerPhase,
status: 'failed',
error: 'Error description',
},
[],
),
active: false,
},
}
export const Maker: Story = {
args: {
value: toScheduleEntry(makerPhase, []),
},
}

View file

@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/ui/jam/StatusBadge'
import { UTXO_STATUS_TAG_VARIANTS } from '@/lib/tags'
@ -24,3 +25,26 @@ export const All: Story = {
</>
),
}
export const WithTooltip: Story = {
render: () => (
<>
<StatusBadge tooltip="Tooltip content">Hover me</StatusBadge>
</>
),
}
export const ButtonAsChild: Story = {
render: () => (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-5">
<StatusBadge asChild variant="cj-out">
<Button variant="ghost">cj-out</Button>
</StatusBadge>
<StatusBadge asChild variant="cj-change">
<Button variant="ghost">cj-change</Button>
</StatusBadge>
</div>
</>
),
}

View file

@ -135,6 +135,18 @@ const mergeStoryArguments = (metaArguments?: Record<string, unknown>, storyArgum
...storyArguments,
})
type DecoratorFn = (Story: ComponentType<Record<string, unknown>>) => ReactElement
const toArray = <T>(value: T | T[] | undefined): T[] => {
if (value === undefined) {
return []
}
if (Array.isArray(value)) {
return value
}
return [value]
}
describe('storybook stories', () => {
beforeEach(() => {
// eslint-disable-next-line compat/compat -- jsdom lacks ResizeObserver, but Radix-powered stories expect it.
@ -143,6 +155,21 @@ describe('storybook stories', () => {
observe = vi.fn()
unobserve = vi.fn()
}
// used is-mobile (e.g. sidebar)
Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: unknown) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})
it('loads every story module', () => {
@ -160,7 +187,13 @@ describe('storybook stories', () => {
for (const module of Object.values(modules)) {
const meta = (
module as { default?: { args?: Record<string, unknown>; component?: ComponentType<Record<string, unknown>> } }
module as {
default?: {
args?: Record<string, unknown>
component?: ComponentType<Record<string, unknown>>
decorators?: Array<DecoratorFn>
}
}
).default
for (const [name, story] of Object.entries(module as Record<string, unknown>)) {
@ -170,7 +203,7 @@ describe('storybook stories', () => {
const storyConfig = story as {
args?: Record<string, unknown>
decorators?: Array<(Story: ComponentType<Record<string, unknown>>) => ReactElement>
decorators?: Array<DecoratorFn>
render?: (args?: Record<string, unknown>) => ReactElement
}
let StoryComponent: ComponentType<Record<string, unknown>>
@ -185,8 +218,7 @@ describe('storybook stories', () => {
continue
}
const decorators: Array<(Story: ComponentType<Record<string, unknown>>) => ReactElement> =
storyConfig.decorators ?? []
const decorators: Array<DecoratorFn> = [...toArray(meta?.decorators), ...toArray(storyConfig.decorators)]
for (const decorator of decorators) {
const DecoratedStory: ComponentType<Record<string, unknown>> = StoryComponent