diff --git a/docs/developing.md b/docs/developing.md index 11b72e17f..b96f4b9dc 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -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). diff --git a/docs/wallet-state-friction-audit.md b/docs/wallet-state-friction-audit.md new file mode 100644 index 000000000..1219a6cb5 --- /dev/null +++ b/docs/wallet-state-friction-audit.md @@ -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. diff --git a/src/components/MainWalletPage.test.tsx b/src/components/MainWalletPage.test.tsx new file mode 100644 index 000000000..d8abd8be6 --- /dev/null +++ b/src/components/MainWalletPage.test.tsx @@ -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 = {}) => { + 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() + 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(' 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() + 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() + 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() + 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() + 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() + const journeyContainer = container.querySelector('[data-journey-state]') + + expect(journeyContainer).toHaveAttribute('data-journey-state', 'funded_not_ready') + }) +}) diff --git a/src/components/MainWalletPage.tsx b/src/components/MainWalletPage.tsx index 0d4b511e4..d98825030 100644 --- a/src/components/MainWalletPage.tsx +++ b/src/components/MainWalletPage.tsx @@ -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' @@ -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() @@ -54,7 +56,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps) walletFileName={walletFileName} selectedJarIndex={selectedJar?.jarIndex} /> -
+

{walletNameTitle} diff --git a/src/hooks/useWalletJourneyState.ts b/src/hooks/useWalletJourneyState.ts new file mode 100644 index 000000000..5535d0f78 --- /dev/null +++ b/src/hooks/useWalletJourneyState.ts @@ -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, + ]) +}