Skip to content
69 changes: 69 additions & 0 deletions docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,75 @@ A place to collect useful information for developers that doesn't really fit els

For a complete development environment you need a local JoinMarket instance that the web UI can interact with. We provide a regtest environment that should give you everything needed to get started developing with JoinMarket. You can find details here: [docker/regtest/readme.md](../docker/regtest/readme.md).

## Simulating Wallet Journey States (Regtest)

Use these shortcuts when testing wallet-state driven UI flows.

### Setup

```sh
npm run regtest:up
npm run dev
```

### Simulate Empty Wallet

Use a fresh environment and do not run initialization funding.

```sh
npm run regtest:down
npm run regtest:clear
npm run regtest:up
# do NOT run: npm run regtest:init
```

Expected result in UI: wallet opens with zero balance and no spendable funds.

### Simulate Funded Wallet

Fund wallets using the bundled init flow:

```sh
npm run regtest:init
```

This funds wallets and starts makers in secondary/tertiary containers.

Expected result in UI: non-zero balance and selectable source jars with available balance.

### Simulate CoinJoin-Ready State

CoinJoin-ready in UI typically means:

- wallet funded with available (not frozen/locked) UTXOs
- enough confirmations (default precondition: 5)
- no active maker/coinjoin/rescan conflict for the action you test

Practical flow:

```sh
npm run regtest:init
npm run regtest:mine
```

Then open Send and select a funded source jar. Preconditions warning should disappear once confirmations/eligibility are satisfied.

### Verify UI State Changes

- Main wallet page exposes a journey marker: inspect `[data-journey-state]` in browser devtools.
- Common values: `no_wallet_or_not_initialized`, `empty_wallet`, `ready_for_coinjoin`, `coinjoin_in_progress`, `funded_not_ready`.
- Cross-check with route behavior:
- disabled/enabled actions on Send, Earn, Sweep
- precondition alerts on Send/Sweep
- fee-config blocker banner and dialog CTA

### Contributor Tips

- Keep `npm run regtest:mine` running while testing confirmation-dependent flows.
- If UI seems stuck, check for maker running / coinjoin running / rescanning state first.
- For fee-related blockers, use the Settings fee dialog from the in-page alert instead of retrying submits.
- Use `npm run regtest:clear` when state gets hard to reason about.

## Linting

We use Create React App's [default ESLint integration](https://create-react-app.dev/docs/setting-up-your-editor/#displaying-lint-output-in-the-editor).
Expand Down
28 changes: 28 additions & 0 deletions docs/wallet-state-friction-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Wallet-State Friction Audit

## Scope

Routes reviewed for silent stuck states and unclear recovery paths:

- `/`
- `/send`
- `/sweep`
- `/earn`

## Findings

| Route | Problem | Why it blocks users | Suggested improvement |
| -------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/send` | Silent form lock when maker is running or wallet is rescanning | The Send form is disabled by state gate logic, but there is no dedicated in-page reason for maker/rescan lock on this route. Users can see controls but cannot proceed, without a clear unblock action. | Add explicit blocking alerts for maker-running and rescanning on Send (same style as existing coinjoin warning), with direct CTA links to `/earn` (stop maker) or `/settings/rescan` status. |
| `/send` | Fee configuration blocker is only fully enforced at coinjoin submit time | Fee missing is shown as a banner, but coinjoin flow is still interactive until submit, then hard-blocks and opens config dialog. This creates a late failure moment. | Make coinjoin path pre-disabled when fee config is missing and show a single inline "Configure fee limits to continue" state near the submit button (keep existing dialog CTA). |
| `/send` | Coinjoin preconditions warning allows "send despite warning" without guided recovery | Preconditions render as warning text and the primary action remains available as "send despite warning", so users may keep retrying instead of resolving root cause (confirmations, missing UTXOs, retry locks). | Add contextual next-step CTA in warning block: "Deposit to source jar", "Wait for confirmations", or "Switch source jar", and only show "send despite warning" for non-blocking warning classes. |
| `/sweep` | Start action can be silently disabled during rescanning | Sweep computes `isOperationDisabled` with rescanning and preconditions, but only maker/coinjoin cases are explicitly alerted. Rescan lock lacks a direct explanatory alert on this page. | Add a rescanning warning alert above controls with a progress/status link to `/settings/rescan`, and include reason text beside disabled start button. |
| `/sweep` | Multi-factor disable state obscures single next step | Start can be disabled by fee config, collaborative activity, preconditions, destination validation, waiting state; user sees a disabled button but may not know which condition is primary. | Add a compact "blocking checklist" summary (first unmet condition only) directly above the Start button, with one actionable instruction at a time. |
| `/earn` | Earn form disabled for coinjoin/rescan without explicit route-level explanation | Form disable logic includes `coinjoin_in_process` and `rescanning`, but route alerts focus on maker state and offer loading; users may encounter disabled form without clear remediation. | Add explicit alerts for "Coinjoin in progress" and "Rescanning in progress" on Earn, with direct guidance ("stop/finish coinjoin first" or "wait for rescan completion"). |
| `/` | Empty wallet state is implicit, not explicit | Zero balance is rendered as a normal value; users must infer they need to fund first. There is no dedicated empty-state explanation or guided path. | Add explicit empty-wallet callout when total balance is zero, with a primary CTA to Receive and a secondary "How coinjoin readiness works" hint. |

## Priority

1. Add explicit reason banners for disabled Send/Earn/Sweep actions (maker, rescanning, active coinjoin).
2. Convert late fee-config failure on Send into an early inline blocker with direct settings action.
3. Add empty-wallet explicit state on Main Wallet with clear first-step CTA.
187 changes: 187 additions & 0 deletions src/components/MainWalletPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import '@testing-library/jest-dom/vitest'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MainWalletPage from '@/components/MainWalletPage'

type WalletContextMock = {
walletName: string | null
isLoading: boolean
isFetching: boolean
error: Error | null
walletBalanceSummary: {
calculatedTotalBalanceInSats: number
calculatedAvailableBalanceInSats: number
calculatedFrozenOrLockedBalanceInSats: number
}
}

type ServiceInfoContextMock =
| {
maker_running?: boolean
coinjoin_in_process?: boolean
schedule?: unknown[]
}
| undefined

const walletContextMock: WalletContextMock = {
walletName: 'Test Wallet',
isLoading: false,
isFetching: false,
error: null,
walletBalanceSummary: {
calculatedTotalBalanceInSats: 100_000,
calculatedAvailableBalanceInSats: 100_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
}

let serviceInfoContextMock: ServiceInfoContextMock = undefined

const setWalletContextMock = (overrides: Partial<WalletContextMock> = {}) => {
Object.assign(walletContextMock, overrides)
}

const setServiceInfoContextMock = (value: ServiceInfoContextMock) => {
serviceInfoContextMock = value
}

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}))

vi.mock('zustand', async (importOriginal) => {
const actual = await importOriginal<typeof import('zustand')>()
return {
...actual,
useStore: (_store: unknown, selector: (state: { state: ServiceInfoContextMock }) => unknown) => {
return selector({ state: serviceInfoContextMock })
},
}
})

vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({ toggleDisplayMode: vi.fn() }),
}))

vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => ({
walletName: walletContextMock.walletName,
isLoading: walletContextMock.isLoading,
isFetching: walletContextMock.isFetching,
error: walletContextMock.error,
refetch: vi.fn(),
walletBalanceSummary: walletContextMock.walletBalanceSummary,
}),
useWalletBalanceSummary: () => ({
walletBalanceSummary: walletContextMock.walletBalanceSummary,
isLoading: walletContextMock.isLoading,
}),
useJars: () => ({ jars: [], isLoading: false }),
}))

vi.mock('@/components/wallet/WalletJarsDetailsOverlay', () => ({
WalletJarsDetailsOverlay: () => null,
}))

describe('<MainWalletPage /> journey state', () => {
beforeEach(() => {
setWalletContextMock({
walletName: 'Test Wallet',
isLoading: false,
isFetching: false,
error: null,
walletBalanceSummary: {
calculatedTotalBalanceInSats: 100_000,
calculatedAvailableBalanceInSats: 100_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
})
setServiceInfoContextMock(undefined)
})

it('sets data-journey-state to no_wallet_or_not_initialized when service is unavailable', () => {
setServiceInfoContextMock(undefined)

const { container } = render(<MainWalletPage walletFileName={'wallet.jmdat'} />)
const journeyContainer = container.querySelector('[data-journey-state]')

expect(journeyContainer).toHaveAttribute('data-journey-state', 'no_wallet_or_not_initialized')
})

it('sets data-journey-state to coinjoin_in_progress when coinjoin is active', () => {
setServiceInfoContextMock({
maker_running: false,
coinjoin_in_process: true,
schedule: [],
})

const { container } = render(<MainWalletPage walletFileName={'wallet.jmdat'} />)
const journeyContainer = container.querySelector('[data-journey-state]')

expect(journeyContainer).toHaveAttribute('data-journey-state', 'coinjoin_in_progress')
})

it('sets data-journey-state to empty_wallet when balance is zero', () => {
setWalletContextMock({
walletBalanceSummary: {
calculatedTotalBalanceInSats: 0,
calculatedAvailableBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
})
setServiceInfoContextMock({
maker_running: false,
coinjoin_in_process: false,
schedule: [],
})

const { container } = render(<MainWalletPage walletFileName={'wallet.jmdat'} />)
const journeyContainer = container.querySelector('[data-journey-state]')

expect(journeyContainer).toHaveAttribute('data-journey-state', 'empty_wallet')
})

it('sets data-journey-state to ready_for_coinjoin when service is available and funds are spendable', () => {
setWalletContextMock({
walletBalanceSummary: {
calculatedTotalBalanceInSats: 250_000,
calculatedAvailableBalanceInSats: 250_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
})
setServiceInfoContextMock({
maker_running: false,
coinjoin_in_process: false,
schedule: [],
})

const { container } = render(<MainWalletPage walletFileName={'wallet.jmdat'} />)
const journeyContainer = container.querySelector('[data-journey-state]')

expect(journeyContainer).toHaveAttribute('data-journey-state', 'ready_for_coinjoin')
})

it('sets data-journey-state to funded_not_ready when maker is running', () => {
setWalletContextMock({
walletBalanceSummary: {
calculatedTotalBalanceInSats: 250_000,
calculatedAvailableBalanceInSats: 250_000,
calculatedFrozenOrLockedBalanceInSats: 0,
},
})
setServiceInfoContextMock({
maker_running: true,
coinjoin_in_process: false,
schedule: [],
})

const { container } = render(<MainWalletPage walletFileName={'wallet.jmdat'} />)
const journeyContainer = container.querySelector('[data-journey-state]')

expect(journeyContainer).toHaveAttribute('data-journey-state', 'funded_not_ready')
})
})
4 changes: 3 additions & 1 deletion src/components/MainWalletPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useWalletBalanceSummary,
type Jar as JarObject,
} from '@/context/JamWalletInfoContext'
import { useWalletJourneyState } from '@/hooks/useWalletJourneyState'
import type { WalletFileName } from '@/lib/utils'
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
import { Balance } from './ui/jam/Balance'
Expand All @@ -31,6 +32,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)

const { toggleDisplayMode } = useJamDisplayContext()
const journeyState = useWalletJourneyState()
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
const { walletBalanceSummary } = useWalletBalanceSummary()
const { jars } = useJars()
Expand All @@ -54,7 +56,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
walletFileName={walletFileName}
selectedJarIndex={selectedJar?.jarIndex}
/>
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12">
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12" data-journey-state={journeyState}>
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-2">
<p className="text-muted-foreground hover:text-foreground text-xl select-all" title={walletName}>
{walletNameTitle}
Expand Down
70 changes: 70 additions & 0 deletions src/hooks/useWalletJourneyState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useMemo } from 'react'
import { useStore } from 'zustand'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { jmSessionStore } from '@/store/jmSessionStore'

export type WalletJourneyState =
| 'no_wallet_or_not_initialized'
| 'coinjoin_in_progress'
| 'empty_wallet'
| 'ready_for_coinjoin'
| 'funded_not_ready'

type ServiceInfoContextValue = {
serviceAvailable: boolean
makerRunning: boolean
coinjoinInProgress: boolean
}

const useServiceInfoContext = (): ServiceInfoContextValue => {
const jmSession = useStore(jmSessionStore, (state) => state.state)

return useMemo(
() => ({
serviceAvailable: jmSession !== undefined,
makerRunning: jmSession?.maker_running === true,
coinjoinInProgress: jmSession?.coinjoin_in_process === true || (jmSession?.schedule?.length || 0) > 0,
}),
[jmSession],
)
}

export function useWalletJourneyState(): WalletJourneyState {
const wallet = useJamWalletInfoContext()
const serviceInfo = useServiceInfoContext()

return useMemo(() => {
const walletReady = wallet.isLoading === false && wallet.error === null
const hasWallet = wallet.walletName !== null

const totalBalanceSats = wallet.walletBalanceSummary.calculatedTotalBalanceInSats
const availableBalanceSats = wallet.walletBalanceSummary.calculatedAvailableBalanceInSats

if (!hasWallet || !walletReady || !serviceInfo.serviceAvailable) {
return 'no_wallet_or_not_initialized'
}

if (serviceInfo.coinjoinInProgress) {
return 'coinjoin_in_progress'
}

if (totalBalanceSats <= 0) {
return 'empty_wallet'
}

if (!serviceInfo.makerRunning && availableBalanceSats > 0) {
return 'ready_for_coinjoin'
}

return 'funded_not_ready'
}, [
serviceInfo.coinjoinInProgress,
serviceInfo.makerRunning,
serviceInfo.serviceAvailable,
wallet.error,
wallet.isLoading,
wallet.walletBalanceSummary.calculatedAvailableBalanceInSats,
wallet.walletBalanceSummary.calculatedTotalBalanceInSats,
wallet.walletName,
])
}