Compare commits

..

No commits in common. "devel" and "v2.0.0-beta.1" have entirely different histories.

158 changed files with 2821 additions and 8248 deletions

5
.env
View file

@ -1,5 +1,5 @@
VITE_JAM_DEFAULT_THEME=dark
VITE_JAM_REPO_URL=https://github.com/joinmarket-webui/jam
VITE_JAM_REPO_URL=https://github.com/joinmarket-webui
VITE_JAM_DOCS_URL=https://jamdocs.org
VITE_JAM_MATRIX_URL=https://matrix.to/#/%23jam:bitcoin.kyoto
VITE_JAM_TELEGRAM_URL=https://t.me/JoinMarketWebUI
@ -23,6 +23,3 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000
VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10
VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS=true
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1000

View file

@ -13,5 +13,3 @@ 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
VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL=5000

View file

@ -3,5 +3,3 @@ 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
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['v26.5.0']
node-version: ['v26.3.0']
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v6
- name: Setup (Node.js ${{ matrix.node-version }})
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
@ -30,7 +30,7 @@ jobs:
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package-lock.json').packages['node_modules/@playwright/test'].version)")" >> $GITHUB_ENV
- name: Cache Playwright
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@v5
id: playwright-cache
with:
path: |
@ -59,4 +59,4 @@ jobs:
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in vite config
if: ${{ always() && hashFiles('coverage/coverage-summary.json') != '' }}
uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2
uses: davelosert/vitest-coverage-report-action@v2

View file

@ -41,9 +41,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View file

@ -12,12 +12,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc

View file

@ -24,10 +24,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
@ -38,13 +38,13 @@ jobs:
run: npm run storybook:build-gh-pages
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
uses: actions/configure-pages@v6
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
uses: actions/upload-pages-artifact@v5
with:
path: storybook-static
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
uses: actions/deploy-pages@v5

2
.nvmrc
View file

@ -1 +1 @@
v26.5.0
v26.3.0

View file

@ -2,8 +2,7 @@
import { Suspense, useEffect } from 'react'
import type { Preview } from '@storybook/react-vite'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { mswLoader } from 'msw-storybook-addon/csf3'
import { setupWorker } from 'msw/browser'
import { initialize, mswLoader } from 'msw-storybook-addon'
import { ThemeProvider } from 'next-themes'
import { I18nextProvider } from 'react-i18next'
import { MemoryRouter } from 'react-router-dom'
@ -18,6 +17,15 @@ import mswHandlers from './msw-handlers'
// needed if you want to use msw on a subpath (e.g. github pages /<repo>)
const mswServiceWorkerUrl = import.meta.env.STORYBOOK_MSW_SERVICE_WORKER_URL ?? '/mockServiceWorker.js'
// Initialize MSW (https://github.com/mswjs/msw-storybook-addon)
initialize({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: mswServiceWorkerUrl,
},
quiet: true,
})
const locales = [
{ value: 'en', title: 'en' },
{ value: 'fr', title: 'fr' },
@ -77,28 +85,12 @@ const preview: Preview = {
viewport: {
options: { ...MINIMAL_VIEWPORTS, ...INITIAL_VIEWPORTS },
},
// this approach is deprecated as of v3.0.0
// but it seems the new approach does not work as expected (404 response for our handlers)
// revisit after next update (last checked 2026-07-30)
// see: https://github.com/mswjs/msw-storybook-addon/blob/v3.0.0/MIGRATION.md#parametersmsw-is-deprecated-in-favor-of-beforeeach
msw: {
handlers: mswHandlers,
},
},
// Provide the MSW addon loader globally
loaders: [
mswLoader(async () => {
const worker = setupWorker()
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: mswServiceWorkerUrl,
},
quiet: true,
})
return worker
}),
],
loaders: [mswLoader],
}
const withTheme = (Story: React.ComponentType, context: GlobalContext) => {

View file

@ -86,8 +86,6 @@ services:
NETWORK_CONFIG__NETWORK: testnet
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher
ORDERBOOK_WATCHER__HTTP_PORT: 8000
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
@ -126,8 +124,6 @@ services:
NETWORK_CONFIG__NETWORK: testnet
NETWORK_CONFIG__BITCOIN_NETWORK: regtest
NETWORK_CONFIG__DIRECTORY_SERVERS: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
ORDERBOOK_WATCHER__HTTP_HOST: joinmarket_ng_orderbook_watcher
ORDERBOOK_WATCHER__HTTP_PORT: 8000
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050

View file

@ -156,8 +156,6 @@ services:
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
LOGGING__LEVEL: DEBUG
ORDERBOOK_WATCHER__HTTP_HOST: 0.0.0.0
ORDERBOOK_WATCHER__HTTP_PORT: 8000
expose:
- 8000
ports:

View file

@ -16,9 +16,7 @@ set -Eeuo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
# fund wallet in primary JoinMarket NG container
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 0 --unmatured --blocks 1
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 1 --unmatured --blocks 2 # outputs should be flagged as "reused"
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 4 --unmatured --blocks 1
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --unmatured --blocks 3
# fund wallet in ng standalone secondary container
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50
@ -39,7 +37,7 @@ dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th addre
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 = at least 106 blocks
# 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

View file

@ -26,7 +26,7 @@ If your separately running joinmarket-ng services use different ports, you can o
JAM_BACKEND=joinmarket-ng \
JMWALLETD_API_PORT=28183 \
JMWALLETD_WEBSOCKET_PORT=28283 \
JMOBWATCH_PORT=8000 \
JMOBWATCH_PORT=8080 \
npm run dev
```

3050
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "jam",
"version": "2.0.0-beta.2",
"version": "2.0.0-beta.1",
"private": true,
"description": "Your sats. Your privacy. Your profit.",
"repository": "git@github.com:joinmarket-webui/jam.git",
@ -71,92 +71,92 @@
]
},
"dependencies": {
"@hookform/resolvers": "5.7.1",
"@hookform/resolvers": "5.4.0",
"@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0",
"@noble/hashes": "2.3.0",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-tooltip": "1.2.16",
"@noble/hashes": "2.2.0",
"@radix-ui/react-accordion": "1.2.16",
"@radix-ui/react-avatar": "1.2.2",
"@radix-ui/react-checkbox": "1.3.7",
"@radix-ui/react-dialog": "1.1.19",
"@radix-ui/react-dropdown-menu": "2.1.20",
"@radix-ui/react-label": "2.1.11",
"@radix-ui/react-popover": "1.1.19",
"@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",
"@radix-ui/react-tooltip": "1.2.12",
"@scure/bip32": "2.2.0",
"@scure/bip39": "2.2.0",
"@tailwindcss/vite": "4.3.3",
"@tailwindcss/vite": "4.3.2",
"@tanstack/match-sorter-utils": "8.19.4",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-table": "8.21.3",
"bitcoin-address-validation": "3.0.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"html5-qrcode": "2.3.8",
"i18next-browser-languagedetector": "8.2.1",
"lucide-react": "1.30.0",
"lucide-react": "1.24.0",
"next-themes": "0.4.6",
"qrcode": "1.5.4",
"radix-ui": "1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.84.0",
"react-i18next": "17.0.11",
"react-router-dom": "7.18.2",
"radix-ui": "1.6.2",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "7.81.0",
"react-i18next": "17.0.9",
"react-router-dom": "7.18.1",
"react-use-websocket": "4.13.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tailwindcss": "4.3.3",
"tailwindcss": "4.3.2",
"tw-animate-css": "1.4.0",
"yup": "1.7.1",
"zustand": "5.0.14"
},
"devDependencies": {
"@chromatic-com/storybook": "5.3.0",
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.0",
"@storybook/addon-a11y": "10.5.7",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-vitest": "10.5.7",
"@storybook/react-vite": "10.5.7",
"@tanstack/eslint-plugin-query": "5.101.4",
"@testing-library/jest-dom": "7.0.0",
"@chromatic-com/storybook": "5.2.1",
"@eslint/js": "9.39.4",
"@playwright/test": "1.61.1",
"@storybook/addon-a11y": "10.5.0",
"@storybook/addon-docs": "10.5.0",
"@storybook/addon-vitest": "10.5.0",
"@storybook/react-vite": "10.5.0",
"@tanstack/eslint-plugin-query": "5.101.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.3",
"@testing-library/user-event": "14.6.1",
"@trivago/prettier-plugin-sort-imports": "6.0.2",
"@types/node": "26.1.2",
"@types/node": "26.1.1",
"@types/qrcode": "1.5.6",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.4",
"@vitejs/plugin-react": "6.0.5",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.3",
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"conventional-changelog": "8.1.1",
"eslint": "9.39.5",
"conventional-changelog": "8.1.0",
"eslint": "9.39.4",
"eslint-plugin-compat": "7.0.2",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-storybook": "10.5.0",
"eslint-plugin-unicorn": "65.0.1",
"globals": "17.9.0",
"globals": "17.7.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"lint-staged": "17.3.0",
"jsdom": "29.1.1",
"lint-staged": "17.0.8",
"msw": "2.15.0",
"msw-storybook-addon": "3.0.0",
"prettier": "3.9.6",
"prettier-plugin-tailwindcss": "0.8.1",
"storybook": "10.5.7",
"msw-storybook-addon": "2.0.7",
"prettier": "3.9.5",
"prettier-plugin-tailwindcss": "0.8.0",
"storybook": "10.5.0",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"vite": "8.2.1",
"typescript-eslint": "8.63.0",
"vite": "8.1.4",
"vitest": "4.1.10"
},
"overrides": {

View file

@ -48,7 +48,6 @@ import { EarnReportPage } from './components/earn/report/EarnReportPage'
import { RootLayout } from './components/layout/RootLayout'
import { LockWalletConfirmDialog } from './components/ui/jam/LockWalletConfirmDialog'
import { Spinner } from './components/ui/spinner'
import { TxHistoryPage } from './components/wallet/TxHistoryPage'
import { WalletJarsDetailsPage } from './components/wallet/WalletJarsDetailsPage'
import { useJamSessionInfoContext } from './context/JamSessionInfoContext'
import { JamSessionInfoContextProvider } from './context/JamSessionInfoContextProvider'
@ -241,7 +240,6 @@ function App() {
path={routes.walletJarsDetails}
element={<WalletJarsDetailsPage walletFileName={walletFileName!} />}
/>
<Route path={routes.txHistory} element={<TxHistoryPage walletFileName={walletFileName!} />} />
{isDeveloperMode && isDebugFeatureEnabled('devPage') && (
<Route
id="dev-page"

View file

@ -32,12 +32,6 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => ({ jars }),
}))
vi.mock('@/store/jamSettingsStore', () => ({
usePreviewFeatures: () => ({
'tx-history': true,
}),
}))
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
WalletJarsDetailsOverlay: ({
open,
@ -59,11 +53,7 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
),
}))
vi.mock('./wallet/TxHistoryContent', () => ({
TxHistoryContent: () => <div data-testid="TxHistoryContent" />,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
vi.mock('./ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>{valueString}</span>
),
@ -84,7 +74,6 @@ const makeJar = (jarIndex: number, name: string): Jar =>
calculatedTotalBalanceInSats: 100,
calculatedAvailableBalanceInSats: 100,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
},
}) as unknown as Jar
@ -135,14 +124,10 @@ describe('MainWalletPage', () => {
expect(refetch).toHaveBeenCalled()
})
it('keeps jars visible and clickable while fetching in the background', () => {
it('shows a jars spinner while fetching and refreshes on click', () => {
walletInfo = { ...walletInfo, isFetching: true }
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByText(jars[0].name)).toBeInTheDocument()
fireEvent.click(screen.getByText(jars[0].name))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent(String(jars[0].jarIndex))
expect(screen.getByTestId('spinner')).toBeInTheDocument()
fireEvent.click(screen.getByText('global.refresh'))
expect(refetch).toHaveBeenCalled()
})
@ -152,17 +137,4 @@ describe('MainWalletPage', () => {
fireEvent.click(screen.getByText('5000'))
expect(toggleDisplayMode).toHaveBeenCalled()
})
it('expands and collapses transaction history via accordion toggle button', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
const toggleButton = screen.getByTitle('tx_history.expand_history')
fireEvent.click(toggleButton)
expect(screen.getByTestId('TxHistoryContent')).toBeInTheDocument()
const collapseButton = screen.getByTitle('tx_history.collapse_history')
fireEvent.click(collapseButton)
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
})
})

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronDownIcon, DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Alert, AlertDescription } from '@/components/ui/alert'
@ -17,11 +17,8 @@ import {
import { getErrorReason } from '@/lib/errorReason'
import type { WalletFileName } from '@/lib/utils'
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
import { usePreviewFeatures } from '@/store/jamSettingsStore'
import { Balance } from './ui/jam/Balance'
import { Spinner } from './ui/spinner'
import { TxHistoryContent } from './wallet/TxHistoryContent'
import { TxHistoryOverlay } from './wallet/TxHistoryOverlay'
import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay'
interface MainWalletPageProps {
@ -31,11 +28,8 @@ interface MainWalletPageProps {
export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const previewFeatures = usePreviewFeatures()
const [selectedJar, setSelectedJar] = useState<JarObject>()
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)
const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false)
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false)
const { toggleDisplayMode } = useJamDisplayContext()
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
@ -61,10 +55,6 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
walletFileName={walletFileName}
selectedJarIndex={selectedJar?.jarIndex}
/>
{previewFeatures?.['tx-history'] === true ? (
<TxHistoryOverlay open={isTxHistoryOpen} onOpenChange={setIsTxHistoryOpen} walletFileName={walletFileName} />
) : null}
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12">
<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}>
@ -97,37 +87,8 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('current_wallet.button_withdraw')}
</Button>
</div>
{previewFeatures?.['tx-history'] === true ? (
<div className="my-6 flex w-full max-w-4xl items-center gap-3">
<div className="bg-border h-px flex-1" />
<Button
type="button"
variant="outline"
size="icon"
className="border-border bg-background size-8 shrink-0 rounded-full"
onClick={() => setIsHistoryExpanded((previous) => !previous)}
title={isHistoryExpanded ? t('tx_history.collapse_history') : t('tx_history.expand_history')}
>
<ChevronDownIcon
className={cn('size-4 transition-transform duration-200', isHistoryExpanded && 'rotate-180')}
/>
</Button>
<div className="bg-border h-px flex-1" />
</div>
) : null}
</div>
{isHistoryExpanded && (
<TxHistoryContent
walletFileName={walletFileName}
compact={true}
initialLimit={5}
onViewAll={() => setIsTxHistoryOpen(true)}
className="w-full max-w-6xl"
/>
)}
{error ? (
<Alert variant="destructive" className="mb-4 max-w-xl">
<AlertDescription>
@ -155,7 +116,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</Tooltip>
</div>
<div className="flex min-h-[128px] items-center justify-center gap-4">
{isLoading ? (
{isFetching ? (
<div className="flex flex-1 items-center justify-center gap-2 py-8">
<Spinner className="motion-reduce:hidden" />
{t('global.loading')}

View file

@ -53,16 +53,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onSelect,
onClick,
disabled,
isSelected,
}: {
name?: string
onSelect?: () => void
onClick?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onSelect={onSelect} disabled={disabled} data-selected={isSelected}>
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),

View file

@ -2,7 +2,6 @@ import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary'
import { generateLockdateOptions } from './types'
import { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
@ -91,7 +90,7 @@ const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
jarIndex,
name: `Jar ${jarIndex}`,
color: '#808080',
balanceSummary: BALANCE_SUMMARY_EMPTY,
balanceSummary: {},
utxos,
}) as unknown as Jar

View file

@ -6,15 +6,11 @@ import { useQuery } from '@tanstack/react-query'
import { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY,
} from '@/constants/jam'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { Utxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import { delayedPromise, type WalletFileName } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import type { JarIndex } from '@/types/global'
import { useFidelityBondMutations } from '../fidelity-bond/useFidelityBondMutations'
@ -110,8 +106,10 @@ export function useCreateFidelityBondWizard(
const jarsWithUtxos = useMemo(() => {
return walletInfo.jars.filter((jar) => {
const availableUtxos = jar.utxos.filter((utxo) =>
fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status),
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
const availableUtxos = jar.utxos.filter(
(utxo) =>
!utxo.frozen && !fb.utxo.isFidelityBond(utxo) && walletInfo.addressSummary[utxo.address]?.status === 'cj-out',
)
return availableUtxos.length > 0
})
@ -119,9 +117,17 @@ export function useCreateFidelityBondWizard(
const availableUtxos = useMemo(() => {
if (selectedJar === undefined) return []
return selectedJar.utxos
.filter((utxo) => fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status))
.toSorted((a, b) => b.value - a.value)
return (
selectedJar.utxos
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
.filter(
(utxo) =>
!utxo.frozen &&
!fb.utxo.isFidelityBond(utxo) &&
walletInfo.addressSummary[utxo.address]?.status === 'cj-out',
)
.toSorted((a, b) => b.value - a.value)
)
}, [selectedJar, walletInfo.addressSummary])
const selectedUtxos = useMemo(() => {
@ -277,15 +283,17 @@ export function useCreateFidelityBondWizard(
setStep('creating')
try {
// TODO: refactor: use the sweep functionality from `useFidelityBondSweep` here
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: selectedJar.jarIndex,
amount_sats: 0, // 0 := sweep!
amount_sats: 0,
destination: address,
},
})
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const utxo of frozenUtxos) {
@ -293,33 +301,12 @@ export function useCreateFidelityBondWizard(
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO.')
} catch {
// logged via onError
}
}
// freeze resulting fidelity bond output
if (JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS) {
try {
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
}
}
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
await walletInfo.refetch()
} catch {
// Best-effort rollback — unfreeze UTXOs that were frozen for this operation
@ -329,9 +316,8 @@ export function useCreateFidelityBondWizard(
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
} catch {
// logged via onError
}
}
setFrozenUtxos([])

View file

@ -3,7 +3,7 @@ import { yupResolver } from '@hookform/resolvers/yup'
import type { TFunction } from 'i18next'
import { HandshakeIcon, PercentIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import type { SubmitHandler } from 'react-hook-form'
import type { Resolver, SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
@ -179,7 +179,8 @@ export function EarnForm({
} = useForm<EarnFormValues, unknown, EarnFormValues>({
mode: 'onSubmit',
defaultValues: FORM_INPUT_DEFAULT_VALUES,
resolver: yupResolver(schema),
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
resolver: yupResolver(schema) as Resolver<EarnFormValues, unknown, EarnFormValues>,
})
const values = useWatch({ control })

View file

@ -2,9 +2,7 @@ import type React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as JAM from '@/constants/jam'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { EarnFormValues } from './EarnForm'
import { EarnPage } from './EarnPage'
@ -12,12 +10,6 @@ import { EarnPage } from './EarnPage'
const mocks = vi.hoisted(() => ({
developerMode: false,
feeConfigMissing: false,
orderbookData: vi.fn<() => unknown>(),
orderbookQueryOptions: vi.fn(),
orderbookQueryState: {
isError: false,
isLoading: false,
},
scrollToTop: vi.fn(),
startMaker: vi.fn(),
startMutationState: {
@ -34,12 +26,17 @@ const mocks = vi.hoisted(() => ({
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
walletInfo: {
addressSummary: {},
fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] },
hasEligibleFidelityBondUtxo: true,
isFetching: false,
isLoading: false,
jars: [] as Jar[],
jars: [] as Array<{
balanceSummary: {
calculatedAvailableBalanceInSats: number
calculatedConfirmedAvailableBalanceInSats: number
calculatedFrozenOrLockedBalanceInSats: number
calculatedTotalBalanceInSats: number
}
}>,
maxJarAvailableBalance: 100_000_000,
},
}))
@ -79,26 +76,15 @@ vi.mock('@tanstack/react-query', () => ({
reset: vi.fn(),
}
}),
useQuery: vi.fn(() => {
return { refetch: mocks.stopMakerRefetch }
}),
}))
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: (options: unknown) => {
mocks.orderbookQueryOptions(options)
return {
queryResult: {
data: mocks.orderbookData(),
...mocks.orderbookQueryState,
},
}
},
useQuery: vi.fn(() => ({
refetch: mocks.stopMakerRefetch,
})),
}))
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) =>
values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey,
Trans: ({ children, i18nKey }: { children?: React.ReactNode; i18nKey?: string }) => (
<span>{children ?? i18nKey}</span>
),
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
@ -180,7 +166,6 @@ vi.mock('./EarnForm', () => ({
<div>
earn-form:{String(disabled)}:{String(debug)}
<button
disabled={disabled}
onClick={() =>
void onSubmit({
offerMinAmount: 50_000,
@ -209,24 +194,9 @@ vi.mock('./MoveToJarDialog', () => ({
}))
vi.mock('./OfferCard', () => ({
OfferCard: ({
children,
nickname,
orderbookStatus,
orderbookOffer,
fidelityBond,
}: {
children?: React.ReactNode
nickname?: string
orderbookStatus?: string
orderbookOffer?: { fidelity_bond_value?: number }
fidelityBond?: { amount?: number }
}) => (
OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => (
<div>
offer-card:{nickname}
<span>orderbook-status:{orderbookStatus}</span>
<span>bond-value:{orderbookOffer?.fidelity_bond_value}</span>
<span>bond-amount:{fidelityBond?.amount}</span>
{children}
</div>
),
@ -242,49 +212,22 @@ vi.mock('./report/EarnReportOverlay', () => ({
const balanceSummary = {
calculatedAvailableBalanceInSats: 100_000_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedConfirmedAvailableBalanceInSats: 100_000_000,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000_000,
}
const eligibleUtxo: Utxo = {
address: 'bcrt1qcj',
confirmations: 12,
external: false,
frozen: false,
label: '',
locktime: undefined,
mixdepth: 0,
path: "m/84'/1'/0'/0/0",
tries: 3,
tries_remaining: 3,
utxo: 'eligible:0',
value: 100_000,
}
const makeJar = (balanceOverrides = {}): Jar => ({
balanceSummary: { ...balanceSummary, ...balanceOverrides },
color: '#e2b86a',
jarIndex: 0,
name: 'Jar 0',
utxos: [eligibleUtxo],
})
const expiredBond: FidelityBondUtxo = {
const expiredBond = {
address: 'bc1qbond',
confirmations: 12,
frozen: false,
label: '',
locktime: '2000-01-01 00:00:00',
locktime: '1970-01-01 00:00:00',
path: "m/84'/1'/0':1",
tries_remaining: 3,
utxo: 'bond-tx:0',
value: 50_000,
tries: 3,
external: false,
mixdepth: 0,
}
} as unknown as FidelityBondUtxo
const setSession = (overrides: Record<string, unknown> = {}) => {
jmSessionStore.setState({
@ -305,11 +248,6 @@ describe('EarnPage', () => {
beforeEach(() => {
mocks.developerMode = false
mocks.feeConfigMissing = false
mocks.orderbookData.mockReset()
mocks.orderbookData.mockReturnValue(undefined)
mocks.orderbookQueryOptions.mockReset()
mocks.orderbookQueryState.isError = false
mocks.orderbookQueryState.isLoading = false
mocks.scrollToTop.mockReset()
mocks.startMaker.mockReset()
mocks.startMaker.mockResolvedValue({})
@ -323,14 +261,10 @@ describe('EarnPage', () => {
mocks.toastError.mockReset()
mocks.toastInfo.mockReset()
mocks.toastSuccess.mockReset()
mocks.walletInfo.addressSummary = {
[eligibleUtxo.address]: { status: 'cj-out' },
}
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
mocks.walletInfo.hasEligibleFidelityBondUtxo = true
mocks.walletInfo.isFetching = false
mocks.walletInfo.isLoading = false
mocks.walletInfo.jars = [makeJar()]
mocks.walletInfo.jars = [{ balanceSummary }]
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
setSession()
})
@ -341,22 +275,21 @@ describe('EarnPage', () => {
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('page-loading')).toBeInTheDocument()
expect(screen.queryByText('submit-earn')).not.toBeInTheDocument()
})
it('starts earning', async () => {
it('starts earning and opens fee/report dialogs', async () => {
const user = userEvent.setup()
mocks.feeConfigMissing = true
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.queryByText('earn.precondition.title')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'earn.button_stop' })).not.toBeInTheDocument()
await user.click(screen.getByText('open-fee-config'))
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'earn.button_show_report' }))
expect(screen.getByText('earn-report:true')).toBeInTheDocument()
await user.click(screen.getByText('submit-earn'))
await waitFor(() => expect(mocks.startMaker).toHaveBeenCalled())
expect(mocks.startMaker).toHaveBeenCalledWith({
body: {
@ -371,21 +304,6 @@ describe('EarnPage', () => {
expect(mocks.scrollToTop).toHaveBeenCalled()
})
it('opens fee/report dialogs', async () => {
const user = userEvent.setup()
mocks.feeConfigMissing = true
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('submit-earn')).toBeEnabled()
await user.click(screen.getByText('open-fee-config'))
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'earn.button_show_report' }))
expect(screen.getByText('earn-report:true')).toBeInTheDocument()
})
it('shows running maker offer and stops it', async () => {
const user = userEvent.setup()
setSession({
@ -395,61 +313,13 @@ describe('EarnPage', () => {
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.alert_running')).toBeInTheDocument()
expect(screen.getByText('offer-card:maker-a')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'earn.button_stop' })).toBeEnabled()
expect(screen.getByText('submit-earn')).toBeDisabled()
expect(screen.getByText('earn.alert_running')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'earn.button_stop' }))
expect(mocks.stopMakerRefetch).toHaveBeenCalledWith({ throwOnError: true })
})
it('shows the current offer and fidelity bond from the local orderbook', () => {
setSession({
maker_running: true,
offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
})
mocks.orderbookData.mockReturnValue({
offers: [{ counterparty: 'maker-a', oid: 7, fidelity_bond_value: 42_000 }],
fidelitybonds: [
{ counterparty: 'maker-a', amount: 50_000, locktime: 1_700_000_000 },
{ counterparty: 'maker-a', amount: 100_000, locktime: 1_800_000_000 },
],
})
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('orderbook-status:visible')).toBeInTheDocument()
expect(screen.getByText('bond-value:42000')).toBeInTheDocument()
expect(screen.getByText('bond-amount:100000')).toBeInTheDocument()
})
it('polls until the current offer is visible, then slows down', () => {
setSession({
maker_running: true,
offer_list: [{ oid: 7, cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }],
})
render(<EarnPage walletFileName="wallet.jmdat" />)
const { refetchInterval } = mocks.orderbookQueryOptions.mock.calls[0][0] as {
refetchInterval: (query: {
state: {
data?: { offers: Array<{ counterparty: string; oid: number }> }
error?: Error | null
}
}) => number
}
const visibleData = { offers: [{ counterparty: 'maker-a', oid: 7 }] }
expect(refetchInterval({ state: {} })).toBe(JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL)
expect(refetchInterval({ state: { data: visibleData } })).toBe(JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL)
expect(refetchInterval({ state: { data: visibleData, error: new Error('offline') } })).toBe(
JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL,
)
})
it('shows waiting states while maker updates', () => {
mocks.startMutationState.isSuccess = true
@ -458,212 +328,63 @@ describe('EarnPage', () => {
expect(screen.getByText('earn.alert_waiting_start_title')).toBeInTheDocument()
})
it('display debug output in developer mode', () => {
it('opens fidelity bond actions', async () => {
const user = userEvent.setup()
mocks.developerMode = true
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText(/walletInfo\.fidelityBondSummary\.fbOutputs/u)).toBeInTheDocument()
})
it('enables creating a fidelity bond', async () => {
const user = userEvent.setup()
expect(mocks.walletInfo.fidelityBondSummary.fbOutputs, 'sanity check').toHaveLength(0)
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
const createFidelityBondButton = screen.getByRole('button', {
name: 'earn.fidelity_bond.create_form.button_create',
})
expect(createFidelityBondButton).toBeEnabled()
await user.click(createFidelityBondButton)
expect(screen.getByText('create-bond-dialog:true')).toBeInTheDocument()
})
it('explains when no UTXO is eligible for fidelity-bond creation', async () => {
const user = userEvent.setup()
mocks.walletInfo.hasEligibleFidelityBondUtxo = false
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.create_form.alert_no_eligible_utxos_description')).toBeInTheDocument()
const createFidelityBondButton = screen.getByRole('button', {
name: 'earn.fidelity_bond.create_form.button_create',
})
expect(createFidelityBondButton).toBeDisabled()
await user.click(createFidelityBondButton)
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
})
it('disables creating a fidelity bond while rescanning', async () => {
const user = userEvent.setup()
setSession({
rescanning: true,
})
expect(mocks.walletInfo.fidelityBondSummary.fbOutputs, 'sanity check').toHaveLength(0)
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
const createFidelityBondButton = screen.getByRole('button', {
name: 'earn.fidelity_bond.create_form.button_create',
})
expect(createFidelityBondButton).toBeDisabled()
await user.click(createFidelityBondButton)
expect(screen.getByText('create-bond-dialog:false')).toBeInTheDocument()
})
it('enables fidelity bond actions on existing fidelity bond', async () => {
const user = userEvent.setup()
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('fidelity-bond:bond-tx:0')).toBeInTheDocument()
expect(screen.getByText(/walletInfo\.fidelityBondSummary\.fbOutputs/u)).toBeInTheDocument()
expect(screen.queryByText('move-to-jar-dialog:false')).not.toBeInTheDocument()
expect(screen.queryByText('move-to-jar-dialog:true')).not.toBeInTheDocument()
expect(screen.queryByText('renew-bond-dialog:false')).not.toBeInTheDocument()
expect(screen.queryByText('renew-bond-dialog:true')).not.toBeInTheDocument()
const moveToJarButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u })
expect(moveToJarButton).toBeEnabled()
await user.click(moveToJarButton)
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u }))
expect(screen.getByText('move-to-jar-dialog:true')).toBeInTheDocument()
const renewButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u })
expect(renewButton).toBeEnabled()
await user.click(renewButton)
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u }))
expect(screen.getByText('renew-bond-dialog:true')).toBeInTheDocument()
})
it('disables fidelity bond actions on existing fidelity bond when rescanning is active', async () => {
const user = userEvent.setup()
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [expiredBond] }
setSession({
rescanning: true,
})
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.queryByText('move-to-jar-dialog:false')).not.toBeInTheDocument()
expect(screen.queryByText('move-to-jar-dialog:true')).not.toBeInTheDocument()
expect(screen.queryByText('renew-bond-dialog:false')).not.toBeInTheDocument()
expect(screen.queryByText('renew-bond-dialog:true')).not.toBeInTheDocument()
const moveToJarButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u })
expect(moveToJarButton).toBeDisabled()
await user.click(moveToJarButton)
expect(screen.queryByText('move-to-jar-dialog:false')).not.toBeInTheDocument()
expect(screen.queryByText('move-to-jar-dialog:true')).not.toBeInTheDocument()
const renewButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u })
expect(renewButton).toBeDisabled()
await user.click(renewButton)
expect(screen.queryByText('renew-bond-dialog:false')).not.toBeInTheDocument()
expect(screen.queryByText('renew-bond-dialog:true')).not.toBeInTheDocument()
})
it('shows the coinjoin-in-progress alert', () => {
setSession({ coinjoin_in_process: true })
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeDisabled()
})
it('shows the waiting-to-stop alert', () => {
setSession({ maker_running: true })
mocks.stopMutationState.isSuccess = true
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.alert_waiting_stop_title')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeDisabled()
})
it('shows the loading-offer alert while the maker runs without an offer', () => {
setSession({ maker_running: true, offer_list: [] })
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.alert_loading_offer')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeDisabled()
})
it('warns when the spendable balance is only unconfirmed', () => {
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
expect(screen.getByText('earn.precondition.hint_missing_confirmations:{"count":1}')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeDisabled()
expect(screen.getByText('earn.alert_unconfirmed_balance_title')).toBeInTheDocument()
})
it('warns when there is no spendable balance at all', () => {
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
mocks.walletInfo.maxJarAvailableBalance = 0
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
expect(screen.getByText('earn.precondition.hint_missing_utxos')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeDisabled()
})
it('warns when there are unfrozen fidelity bonds', () => {
mocks.walletInfo.fidelityBondSummary = {
fbOutputs: [
{
...expiredBond,
frozen: false,
},
],
}
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
expect(screen.getByText('earn.precondition.hint_non_frozen_fidelity_bond:{"count":1}')).toBeInTheDocument()
expect(screen.getByText('submit-earn')).toBeDisabled()
expect(screen.getByText('earn.alert_no_spendable_balance_title')).toBeInTheDocument()
})
it('shows an error toast when starting the maker fails', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const user = userEvent.setup()
mocks.startMaker.mockRejectedValue(new Error('start boom'))
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('submit-earn')).toBeEnabled()
await user.click(screen.getByText('submit-earn'))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
errorSpy.mockRestore()
})
@ -671,12 +392,7 @@ describe('EarnPage', () => {
const user = userEvent.setup()
setSession({ maker_running: true, offer_list: [{ cjfee: '250', minsize: '5000', ordertype: 'sw0absoffer' }] })
mocks.stopMakerRefetch.mockRejectedValue(new Error('stop boom'))
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('submit-earn')).toBeDisabled()
expect(screen.getByRole('button', { name: 'earn.button_stop' })).toBeEnabled()
await user.click(screen.getByRole('button', { name: 'earn.button_stop' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
})

View file

@ -2,7 +2,15 @@ import { useEffect, useMemo, useState } from 'react'
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import type { StartMakerRequest } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { FileTextIcon, HourglassIcon, InfoIcon, PlusIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import {
AlertTriangleIcon,
FileTextIcon,
HourglassIcon,
PlusIcon,
RefreshCwIcon,
ShuffleIcon,
UnlockIcon,
} from 'lucide-react'
import type { SubmitHandler } from 'react-hook-form'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
@ -16,16 +24,13 @@ import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import { isDevMode } from '@/constants/debugFeatures'
import * as JAM from '@/constants/jam'
import { routes } from '@/constants/routes'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import * as OrderbookApi from '@/lib/api/orderbook'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withQueryDelay } from '@/lib/queryClient'
@ -33,16 +38,25 @@ import { cn, isAbsoluteOffer, isRelativeOffer, percentageToFactor, scrollToTop }
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { Milliseconds } from '@/types/global'
import { Spinner } from '../ui/spinner'
import { CreateFidelityBondDialog } from './CreateFidelityBondDialog'
import { EarnForm, type EarnFormValues } from './EarnForm'
import { NonFrozenFidelityBondsAlert, NoSpendableBalanceAlert, UnconfirmedBalanceAlert } from './EarnPreconditionAlerts'
import { FidelityBondCard } from './FidelityBondCard'
import { MoveToJarDialog } from './MoveToJarDialog'
import { OfferCard } from './OfferCard'
import { RenewBondDialog } from './RenewBondDialog'
import { EarnReportOverlay } from './report/EarnReportOverlay'
// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
// Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
// There is currently no way to know for sure - adding a delay at least mitigates the problem.
// 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
const MAKER_STOP_RESPONSE_DELAY: Milliseconds = 2_000
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
const toStartMakerRequest = (values: EarnFormValues): StartMakerRequest => {
// both fee properties need to be provided.
// prevent providing an invalid value by setting the ignored prop to zero
@ -61,7 +75,36 @@ interface EarnPageProps {
walletFileName: WalletFileName
}
const JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED = isDevMode()
const UnconfirmedBalanceAlert = () => {
const { t } = useTranslation()
return (
<Alert variant="warning" className="mb-4">
<AlertTriangleIcon />
<AlertTitle>{t('earn.alert_unconfirmed_balance_title')}</AlertTitle>
<AlertDescription>{t('earn.alert_unconfirmed_balance_description')}</AlertDescription>
</Alert>
)
}
const NoSpendableBalanceAlert = () => {
const { t } = useTranslation()
return (
<Alert variant="warning" className="mb-4">
<AlertTriangleIcon />
<AlertTitle>{t('earn.alert_no_spendable_balance_title')}</AlertTitle>
<AlertDescription>
<Trans i18nKey="earn.alert_no_spendable_balance_description">
<Link to={routes.receive} className="font-semibold">
Fund your wallet
</Link>
to start earning.
</Trans>
</AlertDescription>
</Alert>
)
}
export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const { t } = useTranslation()
@ -75,6 +118,12 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const maxConfirmedJarAvailableBalance = useMemo(() => {
return Math.max(0, ...walletInfo.jars.map((jar) => jar.balanceSummary.calculatedConfirmedAvailableBalanceInSats))
}, [walletInfo.jars])
const earnBalanceWarning =
!walletInfo.isFetching && maxConfirmedJarAvailableBalance < JAM.OFFER_MINSIZE_MIN
? walletInfo.maxJarAvailableBalance >= JAM.OFFER_MINSIZE_MIN
? 'unconfirmed'
: 'unavailable'
: null
const [moveToJarUtxo, setMoveToJarUtxo] = useState<FidelityBondUtxo | undefined>()
const [renewBondUtxo, setRenewBondUtxo] = useState<FidelityBondUtxo | undefined>()
@ -84,34 +133,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
const isCurrentOfferAvailable = jmSession?.offer_list && jmSession.offer_list.length > 0
const currentOffer = jmSession?.offer_list?.[0]
const isCurrentOrderbookOffer = (offer: OrderbookApi.OrderbookOffer) =>
offer.counterparty === jmSession?.nickname && String(offer.oid) === String(currentOffer?.oid)
const {
queryResult: { data: orderbookData, isFetching: orderbookIsFetching, isError: orderbookIsError },
} = useQueryOrderbook({
enabled: makerRunning && !!currentOffer && !!jmSession?.nickname,
staleTime: Number.POSITIVE_INFINITY,
refetchInterval: (query) =>
query.state.error || !query.state.data?.offers.some((offer) => isCurrentOrderbookOffer(offer))
? JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL
: JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL,
})
const currentOrderbookOffer = orderbookData?.offers.find((offer) => isCurrentOrderbookOffer(offer))
const currentOrderbookFidelityBond =
Number(currentOrderbookOffer?.fidelity_bond_value) > 0
? orderbookData?.fidelitybonds?.findLast((bond) => bond.counterparty === jmSession?.nickname)
: undefined
const orderbookStatus =
!jmSession?.nickname || orderbookIsFetching
? 'checking'
: orderbookIsError
? 'error'
: currentOrderbookOffer
? 'visible'
: 'missing'
const stopMakerQueryOptions = stopmakerOptions({
client,
@ -121,7 +142,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const stopMakerQuery = useQuery({
...stopMakerQueryOptions,
queryFn: withQueryDelay(stopMakerQueryOptions.queryFn, {
delayAfter: JAM.MAKER_STOP_RESPONSE_DELAY,
delayAfter: MAKER_STOP_RESPONSE_DELAY,
}),
enabled: false,
retry: false,
@ -161,38 +182,10 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const waitingForMakerUpdate = isWaitingMakerStart || isWaitingMakerStop
const waitingForOfferUpdate = makerRunning && !isCurrentOfferAvailable
const hasFidelityBond = walletInfo.fidelityBondSummary.fbOutputs.length > 0
const isFidelityBondActionsEnabled =
jmSession?.rescanning === false &&
jmSession.maker_running === false &&
jmSession.coinjoin_in_process === false &&
!waitingForMakerUpdate &&
!waitingForOfferUpdate &&
!walletInfo.isFetching
const isCreateFidelityBondEnabled =
isFidelityBondActionsEnabled &&
walletInfo.hasEligibleFidelityBondUtxo &&
(!hasFidelityBond || JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED)
const showCreateAdditionalFidelityBond = JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED
const numberOfNonFrozenFidelityBondOutputs = !hasFidelityBond
? 0
: walletInfo.fidelityBondSummary.fbOutputs.filter((it) => it.frozen !== true).length
const earnPreconditionWarning =
numberOfNonFrozenFidelityBondOutputs > 0
? 'non-frozen-fb-present'
: !walletInfo.isFetching && maxConfirmedJarAvailableBalance < JAM.OFFER_MINSIZE_MIN
? walletInfo.maxJarAvailableBalance >= JAM.OFFER_MINSIZE_MIN
? 'missing-utxos'
: 'missing-confirmations'
: undefined
useRefreshSession({
enabled: waitingForMakerUpdate || waitingForOfferUpdate,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
})
const onStop = async () => {
@ -271,18 +264,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</Alert>
)}
{!isWaitingMakerStart && !isWaitingMakerStop && !jmSession.maker_running && earnPreconditionWarning && (
<>
{earnPreconditionWarning === 'non-frozen-fb-present' ? (
<NonFrozenFidelityBondsAlert numberOfNonFrozenFidelityBondOutputs={numberOfNonFrozenFidelityBondOutputs} />
) : null}
{earnPreconditionWarning === 'missing-utxos' ? (
<UnconfirmedBalanceAlert numberOfMissingConfirmations={1} />
) : null}
{earnPreconditionWarning === 'missing-confirmations' ? <NoSpendableBalanceAlert /> : null}
</>
)}
<div className="flex justify-end">
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
<FileTextIcon />
@ -295,9 +276,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
className="motion-safe:animate-in blur-in"
value={jmSession.offer_list[0]}
nickname={jmSession.nickname}
orderbookStatus={orderbookStatus}
orderbookOffer={currentOrderbookOffer}
fidelityBond={currentOrderbookFidelityBond}
>
<Button type="button" onClick={() => void onStop()} className="w-full" size="lg">
{isWaitingMakerStop ? (
@ -316,23 +294,21 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<Card
className={cn({
hidden: jmSession.maker_running && !waitingForOfferUpdate,
'blur-[2px]':
isWaitingMakerStop ||
waitingForOfferUpdate ||
jmSession.maker_running ||
jmSession.coinjoin_in_process ||
jmSession.rescanning,
'blur-[2px]': isWaitingMakerStop || waitingForOfferUpdate,
})}
>
<CardContent>
<p className="text-muted-foreground mb-4 text-sm">{t('earn.market_explainer')}</p>
{earnBalanceWarning === 'unconfirmed' && <UnconfirmedBalanceAlert />}
{earnBalanceWarning === 'unavailable' && <NoSpendableBalanceAlert />}
<EarnForm
onSubmit={onSubmit}
isWaitingMakerStart={isWaitingMakerStart}
offerMinsizeMax={maxConfirmedJarAvailableBalance}
disabled={
earnPreconditionWarning !== undefined ||
earnBalanceWarning !== null ||
walletInfo.isFetching ||
isWaitingMakerStart ||
isWaitingMakerStop ||
@ -346,7 +322,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</Card>
{/* Fidelity Bonds */}
{!hasFidelityBond ? (
{walletInfo.fidelityBondSummary.fbOutputs.length === 0 ? (
<div
className={cn('mt-8', {
hidden: jmSession.maker_running || waitingForMakerUpdate || waitingForOfferUpdate,
@ -361,29 +337,8 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<CardDescription>{t('earn.fidelity_bond.subtitle')}</CardDescription>
<CardAction></CardAction>
</CardHeader>
{!walletInfo.hasEligibleFidelityBondUtxo && (
<CardContent>
<Alert variant="default">
<InfoIcon />
<AlertTitle>{t('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')}</AlertTitle>
<AlertDescription>
<Trans
i18nKey="earn.fidelity_bond.create_form.alert_no_eligible_utxos_description"
components={{
'1': <Link to={routes.send} className="font-semibold" />,
'3': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</AlertDescription>
</Alert>
</CardContent>
)}
<CardFooter className="gap-2">
<Button
variant="default"
disabled={!isCreateFidelityBondEnabled}
onClick={() => setShowCreateFidelityBondDialog(true)}
>
<Button variant="default" onClick={() => setShowCreateFidelityBondDialog(true)}>
{t('earn.fidelity_bond.create_form.button_create')}
</Button>
</CardFooter>
@ -399,7 +354,14 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<div className="space-y-4">
{walletInfo.fidelityBondSummary.fbOutputs.map((it) => {
const isExpired = !fb.utxo.isLocked(it)
const actionsEnabled = isExpired && isFidelityBondActionsEnabled
const actionsEnabled =
isExpired &&
!jmSession.rescanning &&
!jmSession.maker_running &&
!jmSession.coinjoin_in_process &&
!waitingForMakerUpdate &&
!waitingForOfferUpdate &&
!walletInfo.isFetching
return (
<FidelityBondCard value={it} key={it.utxo}>
{isExpired && (
@ -428,14 +390,10 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
)
})}
</div>
{showCreateAdditionalFidelityBond && (
{/* Existing bonds hide the create button — expose it in developer mode to test multiple bonds */}
{isDeveloperMode && (
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
disabled={!isCreateFidelityBondEnabled}
onClick={() => setShowCreateFidelityBondDialog(true)}
>
<Button variant="outline" onClick={() => setShowCreateFidelityBondDialog(true)}>
<PlusIcon />
{t('earn.fidelity_bond.create_form.button_create')}
</Button>

View file

@ -1,77 +0,0 @@
import { AlertTriangleIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { routes } from '@/constants/routes'
export const UnconfirmedBalanceAlert = ({ numberOfMissingConfirmations }: { numberOfMissingConfirmations: number }) => {
const { t } = useTranslation()
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.precondition.title')}</AlertTitle>
<AlertDescription>
<ul className="list-outside list-disc space-y-2">
<li>
{t('earn.precondition.hint_missing_confirmations', {
count: numberOfMissingConfirmations,
})}
</li>
</ul>
</AlertDescription>
</Alert>
)
}
export const NoSpendableBalanceAlert = () => {
const { t } = useTranslation()
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.precondition.title')}</AlertTitle>
<AlertDescription>
<ul className="list-outside list-disc space-y-2">
<li>
<Trans i18nKey="earn.precondition.hint_missing_utxos">
<Link to={routes.receive} className="font-semibold">
Fund your wallet
</Link>
to start earning.
</Trans>
</li>
</ul>
</AlertDescription>
</Alert>
)
}
export const NonFrozenFidelityBondsAlert = ({
numberOfNonFrozenFidelityBondOutputs,
}: {
numberOfNonFrozenFidelityBondOutputs: number
}) => {
const { t } = useTranslation()
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.precondition.title')}</AlertTitle>
<AlertDescription>
<ul className="list-outside list-disc space-y-2">
<li>
<Trans
i18nKey="earn.precondition.hint_non_frozen_fidelity_bond"
values={{ count: numberOfNonFrozenFidelityBondOutputs }}
components={{
'1': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</li>
</ul>
</AlertDescription>
</Alert>
)
}

View file

@ -34,7 +34,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))

View file

@ -2,19 +2,18 @@ import { useMemo, type PropsWithChildren } from 'react'
import { ClockIcon, CoinsIcon, CopyIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Balance } from '@/components/ui/jam/Balance'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import { cn, time } from '@/lib/utils'
import { time } from '@/lib/utils'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
interface FidelityBondCardProps {
value: FidelityBondUtxo
className?: string
}
export function FidelityBondCard({ value, className, children }: PropsWithChildren<FidelityBondCardProps>) {
export function FidelityBondCard({ value, children }: PropsWithChildren<FidelityBondCardProps>) {
const { t, i18n } = useTranslation()
const isExpired = !fb.utxo.isLocked(value)
@ -32,7 +31,7 @@ export function FidelityBondCard({ value, className, children }: PropsWithChildr
}
return (
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
<Card>
<CardHeader>
<CardTitle>
{isExpired ? (

View file

@ -1,17 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createMoveToJarFormSchema } from './MoveToJarDialog.schema'
describe('createMoveToJarFormSchema', () => {
const schema = createMoveToJarFormSchema([0, 2])
it('accepts an available destination jar', async () => {
await expect(schema.validate({ destinationJarIndex: 2 })).resolves.toEqual({ destinationJarIndex: 2 })
})
it.each([{ destinationJarIndex: 1 }, { destinationJarIndex: undefined }])(
'rejects an unavailable destination jar',
async (values) => {
await expect(schema.isValid(values)).resolves.toBe(false)
},
)
})

View file

@ -1,13 +0,0 @@
import * as yup from 'yup'
import type { JarIndex } from '@/types/global'
export type MoveToJarFormValues = {
destinationJarIndex: JarIndex
}
export const createMoveToJarFormSchema = (availableJarIndexes: JarIndex[]): yup.ObjectSchema<MoveToJarFormValues> =>
yup
.object({
destinationJarIndex: yup.number().oneOf(availableJarIndexes).required(),
})
.required()

View file

@ -4,20 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { MoveToJarDialog } from './MoveToJarDialog'
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type ValueProps = { value: string }
const h = vi.hoisted(() => ({
fetchAddressQueryData: {
queryReturn: {
data: undefined as { address?: string } | undefined,
isLoading: false,
isError: false,
},
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
isPending: false,
refetch: vi.fn(),
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
@ -30,16 +27,13 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => h.fetchAddressQueryData,
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => await options.mutationFn(input),
})),
useQuery: () => h.queryReturn,
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
@ -78,16 +72,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onSelect,
onClick,
disabled,
isSelected,
}: {
name?: string
onSelect?: () => void
onClick?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),
@ -152,9 +146,8 @@ const goToConfirm = () => {
describe('MoveToJarDialog', () => {
beforeEach(() => {
h.fetchAddressQueryData = { data: { address: 'bc111' }, isLoading: false, isError: false }
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
h.freezeAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({})
h.queryReturn = { data: { address: 'bc111' }, isLoading: false, isError: false }
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
h.isPending = false
h.refetch = vi.fn()
h.extraUtxos = []
@ -176,14 +169,14 @@ describe('MoveToJarDialog', () => {
})
it('shows a loading indicator while the destination address loads', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false }
h.queryReturn = { data: undefined, isLoading: true, isError: false }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.move.text_loading')).toBeInTheDocument()
})
it('shows an error alert when the address query fails', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
h.queryReturn = { data: undefined, isLoading: false, isError: true }
renderDialog()
goToConfirm()
expect(screen.getByText('global.errors.error_loading_address_failed')).toBeInTheDocument()
@ -207,12 +200,12 @@ describe('MoveToJarDialog', () => {
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
const calls = h.freezeAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
})
it('returns to the confirm step when sending fails', async () => {
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
renderDialog()
goToConfirm()
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))

View file

@ -1,10 +1,8 @@
import { useMemo, useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { useState } from 'react'
import { getaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangleIcon, UnlockIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertTitle } from '@/components/ui/alert'
@ -13,7 +11,7 @@ import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import type { WalletFileName } from '@/lib/utils'
import { createMoveToJarFormSchema, type MoveToJarFormValues } from './MoveToJarDialog.schema'
import type { JarIndex } from '@/types/global'
import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout'
import {
AddressPreview,
@ -47,25 +45,9 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
const walletInfo = useJamWalletInfoContext()
const [step, setStep] = useState<Step>('select_jar')
const [selectedJarIndex, setSelectedJarIndex] = useState<JarIndex | undefined>()
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const availableJarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars])
const formSchema = useMemo(() => createMoveToJarFormSchema(availableJarIndexes), [availableJarIndexes])
const {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting },
} = useForm<MoveToJarFormValues>({
mode: 'onChange',
defaultValues: {
destinationJarIndex: undefined,
},
resolver: yupResolver(formSchema),
})
const selectedJarIndex = useWatch({ control, name: 'destinationJarIndex' })
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
walletFileName,
utxo,
@ -95,7 +77,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
const handleReset = () => {
setStep('select_jar')
reset()
setSelectedJarIndex(undefined)
setTxResult(undefined)
setError(undefined)
}
@ -107,24 +89,17 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
onOpenChange(newOpen)
}
const submitMove = handleSubmit(async () => {
if (!destinationAddress) return
const handleSubmit = async () => {
if (selectedJarIndex === undefined || !destinationAddress) return
setStep('sending')
const txResult = await sweep({
destination: destinationAddress,
tryFreezeAfterBroadcast: false,
})
setTxResult(txResult)
if (txResult) {
const swept = await sweep(destinationAddress, (result) => {
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.move.success_text'))
} else {
setStep('confirm')
}
})
})
if (!swept) setStep('confirm')
}
const renderFooter = () => {
switch (step) {
@ -142,9 +117,9 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
<WizardStepFooter
onBack={() => setStep('select_jar')}
onCancel={() => handleOpenChange(false)}
onPrimary={() => void submitMove()}
onPrimary={() => void handleSubmit()}
primaryDisabled={!destinationAddress}
isLoading={isLoading || isSubmitting}
isLoading={isLoading}
primaryLabel={t('earn.fidelity_bond.move.text_button_submit')}
/>
)
@ -175,13 +150,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
<FidelityBondJarSelector
selectedJarIndex={selectedJarIndex}
onSelect={(jarIndex) =>
setValue('destinationJarIndex', jarIndex, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
onSelect={setSelectedJarIndex}
isJarDisabled={() => false}
/>
</div>

View file

@ -1,7 +1,6 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import { OfferCard } from './OfferCard'
type Offer = NonNullable<SessionResponse['offer_list']>[number]
@ -19,7 +18,7 @@ vi.mock('@/lib/utils', () => ({
isRelativeOffer: (type: string) => type === 'sw0relo',
}))
vi.mock('@/components/ui/jam/Balance', () => ({
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))
@ -80,41 +79,4 @@ describe('OfferCard', () => {
expect(screen.getByTestId('child')).toBeInTheDocument()
})
it('shows whether the offer is visible in the local orderbook', () => {
const { rerender } = render(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="visible" />)
expect(screen.getByText('earn.current.text_orderbook_visible')).toBeInTheDocument()
rerender(<OfferCard value={baseOffer} nickname="JMBot" orderbookStatus="missing" />)
expect(screen.getByText('earn.current.text_orderbook_missing')).toBeInTheDocument()
})
it('shows the advertised fidelity bond details', () => {
withRuntimeLocale('de-DE', () => {
render(
<OfferCard
value={baseOffer}
nickname="JMBot"
orderbookStatus="visible"
orderbookOffer={{
counterparty: 'JMBot',
oid: 123,
ordertype: 'sw0absoffer',
minsize: 10_000,
maxsize: 50_000,
txfee: 500,
cjfee: 1_000,
fidelity_bond_value: 42_000,
}}
fidelityBond={{ counterparty: 'JMBot', amount: 100_000, locktime: 1_800_000_000 }}
/>,
)
expect(screen.getByText('earn.current.text_fidelity_bond')).toBeInTheDocument()
expect(screen.getByText('earn.current.text_bond_value: 42.000')).toBeInTheDocument()
expect(screen.getByText('100000')).toBeInTheDocument()
expect(screen.getByText(/earn\.current\.text_bond_locktime/u)).toBeInTheDocument()
})
})
})

View file

@ -1,22 +1,11 @@
import type { PropsWithChildren } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { TFunction } from 'i18next'
import {
CircleAlertIcon,
CircleCheckIcon,
FingerprintIcon,
HandCoinsIcon,
Maximize2Icon,
Minimize2Icon,
PickaxeIcon,
SearchIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { FingerprintIcon, HandCoinsIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { OrderbookFidelityBond, OrderbookOffer } from '@/lib/api/orderbook'
import { cn, factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '@/lib/utils'
import { Balance } from '../ui/jam/Balance'
import { Label } from '../ui/label'
@ -47,50 +36,16 @@ interface OfferCardProps {
className?: string
value: Offer
nickname: SessionResponse['nickname']
orderbookStatus?: 'checking' | 'visible' | 'missing' | 'error'
orderbookOffer?: OrderbookOffer
fidelityBond?: OrderbookFidelityBond
}
const orderbookStatusEntry = (value: OfferCardProps['orderbookStatus'], t: TFunction) => {
return value === 'visible'
? { icon: CircleCheckIcon, text: t('earn.current.text_orderbook_visible'), variant: 'success' as const }
: value === 'missing'
? { icon: SearchIcon, text: t('earn.current.text_orderbook_missing'), variant: 'warning' as const }
: value === 'error'
? { icon: CircleAlertIcon, text: t('earn.current.text_orderbook_error'), variant: 'destructive' as const }
: value === 'checking'
? { icon: SearchIcon, text: t('earn.current.text_orderbook_checking'), variant: 'muted' as const }
: undefined
}
export function OfferCard({
className,
value,
nickname,
orderbookStatus,
orderbookOffer,
fidelityBond,
children,
}: PropsWithChildren<OfferCardProps>) {
export function OfferCard({ className, value, nickname, children }: PropsWithChildren<OfferCardProps>) {
const { t } = useTranslation()
const bondValue = Number(orderbookOffer?.fidelity_bond_value) || 0
const offerInOrderbookStatus = orderbookStatusEntry(orderbookStatus, t)
const bondWarning = bondValue === 0 || orderbookOffer?.fidelity_bond_verification_stale === true
return (
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
<Card className={cn('w-full', className)}>
<CardHeader>
<CardTitle>{t('earn.current.text_offer')}</CardTitle>
<CardDescription>
{offerInOrderbookStatus && (
<Badge className="whitespace-normal" variant={offerInOrderbookStatus.variant}>
<offerInOrderbookStatus.icon className="shrink-0" />
{offerInOrderbookStatus.text}
</Badge>
)}
</CardDescription>
<CardDescription></CardDescription>
<CardAction>
<Tooltip>
<TooltipTrigger asChild>
@ -145,9 +100,8 @@ export function OfferCard({
</div>
</div>
{!!value?.txfee && (
<div className="flex min-w-0 items-start gap-4">
<PickaxeIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<Label className="font-semibold">{t('earn.current.text_txfee')}</Label>
<span className="text-muted-foreground text-sm">
<Balance valueString={String(value?.txfee || '0')} />
@ -155,45 +109,6 @@ export function OfferCard({
</div>
</div>
)}
{fidelityBond !== undefined && (
<div className="flex min-w-0 items-start gap-4 sm:col-span-full">
<ShieldCheckIcon
className={cn('mt-0.5 shrink-0', {
'text-brand-success': bondValue > 0,
'text-brand-warning': bondWarning,
})}
/>
<div className="min-w-0 flex-1">
<Label
className={cn('font-semibold', {
'text-brand-success': bondValue > 0,
'text-brand-warning': bondWarning,
})}
>
{t('earn.current.text_fidelity_bond')}
</Label>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 sm:flex-row sm:items-center">
<span className={bondValue <= 0 ? 'text-muted-foreground' : undefined}>
{t('earn.current.text_bond_value')}: {Math.floor(bondValue).toLocaleString()}
</span>
</div>
<div className="text-muted-foreground">
<Balance valueString={String(fidelityBond.amount)} />
<span className="text-muted-foreground">
{t('earn.current.text_bond_locktime', {
date: new Date(fidelityBond.locktime * 1_000).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})}
</span>
</div>
</div>
</div>
</div>
)}
</CardContent>
<CardFooter className="flex-col gap-2">{children}</CardFooter>
</Card>

View file

@ -1,16 +0,0 @@
import { describe, expect, it } from 'vitest'
import { renewBondFormSchema } from './RenewBondDialog.schema'
describe('renewBondFormSchema', () => {
it('accepts a lockdate after confirmation', async () => {
const values = { lockdate: '2030-01' as const, confirmationAccepted: true }
await expect(renewBondFormSchema.validate(values)).resolves.toEqual(values)
})
it.each([
{ lockdate: undefined, confirmationAccepted: true },
{ lockdate: '2030-01' as const, confirmationAccepted: false },
])('rejects incomplete renewal values', async (values) => {
await expect(renewBondFormSchema.isValid(values)).resolves.toBe(false)
})
})

View file

@ -1,19 +0,0 @@
import * as yup from 'yup'
import * as fb from '@/lib/fidelityBondUtils'
export type RenewBondFormValues = {
lockdate?: fb.Lockdate
confirmationAccepted: boolean
}
export const RENEW_BOND_FORM_DEFAULT_VALUES: RenewBondFormValues = {
lockdate: undefined,
confirmationAccepted: false,
}
export const renewBondFormSchema: yup.ObjectSchema<RenewBondFormValues> = yup
.object({
lockdate: yup.string<fb.Lockdate>().required(),
confirmationAccepted: yup.boolean().oneOf([true]).required(),
})
.required()

View file

@ -4,15 +4,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { RenewBondDialog } from './RenewBondDialog'
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
type ChildrenProps = { children?: ReactNode }
type ValueProps = { value: string }
const h = vi.hoisted(() => ({
fetchAddressQueryData: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
queryReturn: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
isPending: false,
refetch: vi.fn(),
utxoFrozen: false,
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
@ -26,16 +24,13 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => h.fetchAddressQueryData,
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => await options.mutationFn(input),
})),
useQuery: () => h.queryReturn,
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
@ -181,9 +176,9 @@ const goToConfirm = () => {
describe('RenewBondDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
h.fetchAddressQueryData = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
h.freezeAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({})
h.queryReturn = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
h.isPending = false
h.utxoFrozen = false
h.extraUtxos = []
h.selectHandlers = []
@ -222,33 +217,27 @@ describe('RenewBondDialog', () => {
})
it('shows a loading spinner while the timelock address loads', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false }
h.queryReturn = { data: undefined, isLoading: true, isError: false }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.renew.text_loading')).toBeInTheDocument()
})
it('shows an error alert when the address query fails', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
h.queryReturn = { data: undefined, isLoading: false, isError: true }
renderDialog()
expect(screen.getByText('earn.fidelity_bond.error_loading_address')).toBeInTheDocument()
})
it('completes the renew flow and reaches the success step', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
renderDialog()
expect(screen.queryByText('earn.fidelity_bond.renew.success_text')).not.toBeInTheDocument()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
expect(screen.getAllByText('abcd1234').length).toBeGreaterThan(0)
expect(h.refetch).toHaveBeenCalled()
})
@ -260,40 +249,13 @@ describe('RenewBondDialog', () => {
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
const calls = h.freezeAsync.mock.calls as unknown as Array<
[{ body?: { 'utxo-string'?: string; freeze?: boolean } }]
>
expect(
calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false),
).toBe(true)
})
it('freezes a fidelity bond after sending', async () => {
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
render(<RenewBondDialog open onOpenChange={vi.fn()} walletFileName={'test.jmdat'} utxo={frozenUtxo} />)
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
const calls = h.freezeAsync.mock.calls as unknown as Array<
[{ body?: { 'utxo-string'?: string; freeze?: boolean } }]
>
// unfreezes the expired fb utxo
expect(
calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false),
).toBe(true)
// freezes the renewed fb utxo
expect(
calls.some((call) => call[0]?.body?.['utxo-string'] === 'abcd1234:0' && call[0]?.body?.freeze === true),
).toBe(true)
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
})
it('returns to the confirm step when sending fails', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))

View file

@ -1,20 +1,16 @@
import { useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { gettimelockaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangleIcon, CalendarIcon, RefreshCwIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS } from '@/constants/jam'
import { useApiClient } from '@/hooks/useApiClient'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import type { WalletFileName } from '@/lib/utils'
import { RENEW_BOND_FORM_DEFAULT_VALUES, renewBondFormSchema, type RenewBondFormValues } from './RenewBondDialog.schema'
import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout'
import {
AddressPreview,
@ -48,25 +44,10 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
const client = useApiClient()
const [step, setStep] = useState<Step>('select_date')
const [selectedLockdate, setSelectedLockdate] = useState<fb.Lockdate | ''>('')
const [confirmationChecked, setConfirmationChecked] = useState(false)
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting },
} = useForm<RenewBondFormValues>({
mode: 'onChange',
defaultValues: RENEW_BOND_FORM_DEFAULT_VALUES,
resolver: yupResolver(renewBondFormSchema),
})
const selectedLockdate = useWatch({ control, name: 'lockdate' })
const confirmationChecked = useWatch({
control,
name: 'confirmationAccepted',
})
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
walletFileName,
utxo,
@ -95,7 +76,8 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
const handleReset = () => {
setStep('select_date')
reset(RENEW_BOND_FORM_DEFAULT_VALUES)
setSelectedLockdate('')
setConfirmationChecked(false)
setTxResult(undefined)
setError(undefined)
}
@ -107,24 +89,17 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
onOpenChange(newOpen)
}
const submitRenewal = handleSubmit(async () => {
const handleSubmit = async () => {
if (!destinationAddress) return
setStep('sending')
const txResult = await sweep({
destination: destinationAddress,
tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
})
setTxResult(txResult)
if (txResult) {
const swept = await sweep(destinationAddress, (result) => {
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.renew.success_text'))
} else {
setStep('confirm')
}
})
})
if (!swept) setStep('confirm')
}
const renderFooter = () => {
switch (step) {
@ -142,9 +117,9 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
<WizardStepFooter
onBack={() => setStep('select_date')}
onCancel={() => handleOpenChange(false)}
onPrimary={() => void submitRenewal()}
onPrimary={() => void handleSubmit()}
primaryDisabled={!confirmationChecked || !destinationAddress}
isLoading={isLoading || isSubmitting}
isLoading={isLoading}
primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')}
/>
)
@ -173,17 +148,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
<FidelityBondAmount value={utxo.value} className="text-lg" />
</InfoCard>
<LockdateSelect
id="renew-lockdate"
value={selectedLockdate ?? ''}
onChange={(lockdate) =>
setValue('lockdate', lockdate || undefined, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
<LockdateSelect id="renew-lockdate" value={selectedLockdate} onChange={setSelectedLockdate} />
</div>
)}
@ -228,13 +193,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
<ConfirmationToggle
id="renew-confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) =>
setValue('confirmationAccepted', checked, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
onCheckedChange={setConfirmationChecked}
/>
</div>
)}

View file

@ -52,7 +52,6 @@ export function FidelityBondAmount({ value, className }: { value: AmountSats; cl
return <Balance valueString={String(value)} className={cn('font-mono text-2xl font-bold', className)} />
}
/* TODO: move component to different namespace */
export function JarBadge({ jarIndex, name }: { jarIndex: JarIndex | undefined; name?: string }) {
const { t } = useTranslation()
return (
@ -62,7 +61,6 @@ export function JarBadge({ jarIndex, name }: { jarIndex: JarIndex | undefined; n
{name} <span>#{jarIndex}</span>
</>
) : (
/* TODO: i18n - move key to different namespace */
t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: jarIndex })
)}
</Badge>

View file

@ -24,7 +24,7 @@ export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisab
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={selectedJarIndex === jar.jarIndex}
onSelect={() => onSelect(jar.jarIndex)}
onClick={() => onSelect(jar.jarIndex)}
disabled={isJarDisabled(jar)}
/>
))}

View file

@ -26,19 +26,16 @@ export function useFidelityBondMutations({
const freezeUtxo = useMutation({
...freezeMutation({ client }),
retry: false,
onError: (error) => setErrorWithReason(error, t('global.errors.error_freezing_utxos')),
})
const unfreezeUtxo = useMutation({
...freezeMutation({ client }),
retry: false,
onError: (error) => setErrorWithReason(error, t(unfreezeErrorKey)),
})
const directSend = useMutation({
...directsendMutation({ client }),
retry: false,
onError: (error) => setErrorWithReason(error, t(sendErrorKey)),
})

View file

@ -1,208 +0,0 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { BALANCE_SUMMARY_EMPTY } from '@/lib/balanceSummary'
import { useFidelityBondSweep } from './useFidelityBondSweep'
const mocks = vi.hoisted(() => ({
freezeMutateAsync: vi.fn(),
unfreezeMutateAsync: vi.fn(),
directSendMutateAsync: vi.fn(),
setError: vi.fn(),
walletInfoRefetch: vi.fn(),
walletInfo: {
jars: [] as Jar[],
refetch: vi.fn(),
},
}))
// `useFidelityBondSweep` only talks to the network through these two hooks —
// mock at that boundary and let the hook's own orchestration logic run for real.
vi.mock('@tanstack/react-query', () => ({
useMutation: vi.fn((options: { mutationFn: (variables: unknown) => Promise<unknown> }) => ({
mutateAsync: options.mutationFn,
isPending: false,
})),
}))
vi.mock('./useFidelityBondMutations', () => ({
useFidelityBondMutations: () => ({
freezeUtxo: { mutateAsync: mocks.freezeMutateAsync },
unfreezeUtxo: { mutateAsync: mocks.unfreezeMutateAsync },
directSend: { mutateAsync: mocks.directSendMutateAsync },
error: undefined,
setError: mocks.setError,
}),
}))
vi.mock('@/context/JamWalletInfoContext', async (importOriginal) => ({
...(await importOriginal<typeof import('@/context/JamWalletInfoContext')>()),
useJamWalletInfoContext: () => mocks.walletInfo,
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/utils')>()),
delayedPromise: vi.fn(() => Promise.resolve()),
}))
const utxo = (overrides: Partial<Utxo>): Utxo => ({
utxo: 'tx:0',
address: 'bcrt1qsource',
path: "m/84'/1'/0'/0/0",
label: '',
value: 100_000,
tries: 0,
tries_remaining: 3,
external: false,
mixdepth: 0,
confirmations: 6,
frozen: false,
locktime: undefined,
...overrides,
})
const bondUtxo = (overrides: Partial<FidelityBondUtxo>): FidelityBondUtxo => ({
...utxo({ utxo: 'bond:0', frozen: true, ...overrides }),
locktime: '2999-01-01 00:00:00',
})
const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
({
jarIndex,
name: `Jar ${jarIndex}`,
color: '#808080',
balanceSummary: BALANCE_SUMMARY_EMPTY,
utxos,
}) as unknown as Jar
describe('useFidelityBondSweep', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.freezeMutateAsync.mockResolvedValue(undefined)
mocks.unfreezeMutateAsync.mockResolvedValue(undefined)
mocks.walletInfoRefetch.mockResolvedValue(undefined)
mocks.walletInfo.refetch = mocks.walletInfoRefetch
})
it('re-freezes the fidelity bond after a failed sweep that had unfrozen it', async () => {
const bond = bondUtxo({ mixdepth: 0 })
const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false })
mocks.walletInfo.jars = [jar(0, [bond, other])]
mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed'))
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
const txResult = await result.current.sweep({
destination: 'bcrt1qdestination',
tryFreezeAfterBroadcast: false,
})
expect(txResult).toBeUndefined()
// the other jar utxo was frozen then correctly rolled back
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'other:0', freeze: true },
})
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }),
)
// the bond itself was unfrozen before the failed send, and MUST be re-frozen in rollback
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { 'utxo-string': 'bond:0', freeze: false },
})
expect(mocks.freezeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
)
})
it('does not attempt to re-freeze the bond on failure when it was never unfrozen', async () => {
const bond = bondUtxo({ mixdepth: 0, frozen: false })
mocks.walletInfo.jars = [jar(0, [bond])]
mocks.directSendMutateAsync.mockRejectedValue(new Error('broadcast failed'))
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
await result.current.sweep({ destination: 'bcrt1qdestination', tryFreezeAfterBroadcast: false })
expect(mocks.unfreezeMutateAsync).not.toHaveBeenCalled()
expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
)
})
it('sweeps successfully: freezes other utxos, unfreezes the bond, then restores the others', async () => {
const bond = bondUtxo({ mixdepth: 0 })
const other = utxo({ utxo: 'other:0', mixdepth: 0, frozen: false })
mocks.walletInfo.jars = [jar(0, [bond, other])]
mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } })
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
const txResult = await result.current.sweep({
destination: 'bcrt1qdestination',
tryFreezeAfterBroadcast: false,
})
expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } })
expect(mocks.directSendMutateAsync).toHaveBeenCalledWith({
path: { walletname: 'wallet.jmdat' },
body: { mixdepth: 0, amount_sats: 0, destination: 'bcrt1qdestination' },
})
// the other utxo ends up unfrozen again post-broadcast
expect(mocks.unfreezeMutateAsync).toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'other:0', freeze: false } }),
)
expect(mocks.walletInfoRefetch).toHaveBeenCalled()
})
it('does not re-freeze the bond if the sweep broadcast but the refetch after it fails', async () => {
const bond = bondUtxo({ mixdepth: 0 })
mocks.walletInfo.jars = [jar(0, [bond])]
mocks.directSendMutateAsync.mockResolvedValue({ txinfo: { txid: 'renewed-tx' } })
mocks.walletInfoRefetch.mockRejectedValue(new Error('refetch failed'))
const { result } = renderHook(() =>
useFidelityBondSweep({
walletFileName: 'wallet.jmdat',
utxo: bond,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
}),
)
const txResult = await result.current.sweep({
destination: 'bcrt1qdestination',
tryFreezeAfterBroadcast: false,
})
expect(txResult).toEqual({ txinfo: { txid: 'renewed-tx' } })
expect(mocks.freezeMutateAsync).not.toHaveBeenCalledWith(
expect.objectContaining({ body: { 'utxo-string': 'bond:0', freeze: true } }),
)
})
})

View file

@ -1,10 +1,8 @@
import { useMemo } from 'react'
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useMutation } from '@tanstack/react-query'
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY } from '@/constants/jam'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { delayedPromise, type WalletFileName } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { useFidelityBondMutations } from './useFidelityBondMutations'
/**
@ -24,13 +22,13 @@ export function useFidelityBondSweep({
unfreezeErrorKey: string
sendErrorKey: string
}) {
const { jars, refetch: walletInfoRefetch } = useJamWalletInfoContext()
const walletInfo = useJamWalletInfoContext()
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
unfreezeErrorKey,
sendErrorKey,
})
const sourceJar = jars.find((jar) => jar.jarIndex === utxo.mixdepth)
const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
const utxosToFreeze = useMemo(() => {
@ -43,130 +41,69 @@ export function useFidelityBondSweep({
* before the frozen UTXOs are restored. Returns false when the sweep failed
* (after a best-effort rollback).
*/
const { isPending, mutateAsync: sweep } = useMutation({
mutationFn: async ({
destination,
tryFreezeAfterBroadcast,
onBroadcastSuccess,
}: {
destination: string
tryFreezeAfterBroadcast: boolean
onBroadcastSuccess?: (result: DirectSendResponse) => Promise<void>
}): Promise<DirectSendResponse | undefined> => {
setError(undefined)
const sweep = async (destination: string, onBroadcast: (result: DirectSendResponse) => void): Promise<boolean> => {
setError(undefined)
const frozen: Utxo[] = []
let bondWasUnfrozen = false
let sweepBroadcasted = false
const frozen: Utxo[] = []
try {
// Freeze other UTXOs in the source jar so only the FB gets swept
for (const u of utxosToFreeze) {
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: true },
})
frozen.push(u)
}
try {
// Freeze other UTXOs in the source jar so only the FB gets swept
for (const u of utxosToFreeze) {
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: true },
})
frozen.push(u)
}
if (utxo.frozen) {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
}
if (utxo.frozen) {
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: utxo.mixdepth,
amount_sats: 0,
destination,
},
})
onBroadcast(result)
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
body: { 'utxo-string': u.utxo, freeze: false },
})
bondWasUnfrozen = true
}
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: utxo.mixdepth,
amount_sats: 0, // 0 := sweep!
destination,
},
})
sweepBroadcasted = true
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO.')
}
}
if (tryFreezeAfterBroadcast) {
try {
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
}
}
try {
await onBroadcastSuccess?.(result)
} catch (error: unknown) {
console.warn('onBroadcastSuccess failed', error)
}
return result
} catch (_ignoredOnPurpose: unknown) {
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
}
}
// re-freeze the bond if it was unfrozen before the error. skip once the
// sweep already broadcast, the bond utxo is spent by then
if (bondWasUnfrozen && !sweepBroadcasted) {
try {
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.debug('Error while re-freezing fidelity bond UTXO in error handling.')
}
}
return undefined
} finally {
// refetch only if the sweep broadcast, and don't let a refetch error
// get treated as a sweep failure
if (sweepBroadcasted) {
try {
await walletInfoRefetch()
} catch (error: unknown) {
console.warn('Error while refetching wallet info after sweep.', error)
}
} catch {
// logged via onError
}
}
},
retry: false,
})
return { sweep, isLoading: isPending, error, setError, sourceJar }
await walletInfo.refetch()
return true
} catch {
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
})
} catch {
// logged via onError
}
}
return false
}
}
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
return { sweep, isLoading, error, setError, sourceJar }
}

View file

@ -118,42 +118,18 @@ describe('EarnReportContent', () => {
vi.restoreAllMocks()
})
it('renders loading and report states', () => {
it('renders loading and empty report states', () => {
mocks.isLoading = true
mocks.isRefetching = false
mocks.entries = []
const { rerender } = render(<EarnReportContent enabled />)
expect(screen.getByText('spinner')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'global.refresh' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'global.download' })).not.toBeInTheDocument()
mocks.isLoading = false
mocks.isRefetching = true
mocks.entries = []
rerender(<EarnReportContent enabled />)
expect(screen.getByText('earn.alert_empty_report')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled()
mocks.isLoading = false
mocks.isRefetching = false
mocks.entries = []
rerender(<EarnReportContent enabled />)
expect(screen.queryByText('earn.alert_empty_report')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'global.download' })).toBeDisabled()
mocks.isLoading = false
mocks.isRefetching = false
mocks.entries = [entry({ earnedAmount: 100, notes: 'first maker note' })]
rerender(<EarnReportContent enabled />)
expect(screen.queryByText('earn.alert_empty_report')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'global.refresh' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'global.download' })).toBeEnabled()
expect(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u })).toBeDisabled()
})
it('summarizes, filters, refreshes, paginates, and exports report rows', async () => {
@ -176,78 +152,37 @@ describe('EarnReportContent', () => {
expect(screen.getByText('chart:3')).toBeInTheDocument()
expect(screen.getByText('balance:600')).toBeInTheDocument()
expect(screen.getAllByText('balance:300').length).toBeGreaterThan(0)
expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument()
expect(screen.queryByText(/earn\.report\.text_report_summary_filtered:/u)).not.toBeInTheDocument()
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":3\}/u)).toBeInTheDocument()
expect(screen.getByText('first maker note')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('earn.report.placeholder_search'), {
target: { value: 'second' },
})
expect(screen.queryByText(/earn\.report\.text_report_summary:/u)).not.toBeInTheDocument()
expect(screen.getByText('earn.report.text_report_summary_filtered:{"count":1}')).toBeInTheDocument()
expect(screen.getByText(/earn\.report\.text_report_summary_filtered/u)).toBeInTheDocument()
expect(screen.queryByText('first maker note')).not.toBeInTheDocument()
expect(screen.getByText('second maker note')).toBeInTheDocument()
const downloadButton = screen.getByRole('button', { name: 'global.download' })
expect(downloadButton).toBeEnabled()
fireEvent.click(downloadButton)
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u }))
expect(mocks.createObjectURL).toHaveBeenCalled()
expect(anchorClick).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'first-page' }))
fireEvent.click(screen.getByRole('button', { name: 'show-all' }))
fireEvent.click(screen.getByRole('button', { name: 'show-page-size' }))
const refreshButton = screen.getByRole('button', { name: 'global.refresh' })
expect(refreshButton).toBeEnabled()
fireEvent.click(refreshButton)
fireEvent.click(screen.getByRole('button', { name: '' }))
await waitFor(() => expect(mocks.refetch).toHaveBeenCalled())
createElement.mockRestore()
})
it('exports all filtered rows regardless of pagination', async () => {
mocks.entries = Array.from({ length: 30 }, (_, index) =>
entry({
notes: `maker note ${index + 1}`,
timestamp: new Date(Date.UTC(2026, 0, index + 1, 12)),
}),
)
render(<EarnReportContent enabled />)
expect(screen.getByText('pagination:30')).toBeInTheDocument()
expect(screen.getAllByText(/maker note/u)).toHaveLength(25)
fireEvent.click(screen.getByRole('button', { name: 'global.download' }))
const blob = mocks.createObjectURL.mock.calls[0]?.[0]
expect(blob).toBeInstanceOf(Blob)
if (!blob) {
throw new Error('CSV blob was not created')
}
const csv = await blob.text()
const lines = csv.trim().split('\n')
expect(lines).toHaveLength(31)
expect(lines[1]).toContain('2026-01-30T12:00:00.000Z')
expect(lines.at(-1)).toContain('2026-01-01T12:00:00.000Z')
})
it('adds demo rows in developer mode', () => {
mocks.developerMode = true
render(<EarnReportContent enabled />)
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText('earn.report.text_report_summary:{"count":3}')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_generate_demo_report/u }))
expect(screen.getByText('earn.report.text_report_summary:{"count":4}')).toBeInTheDocument()
expect(screen.getByText('dev-badge')).toBeInTheDocument()
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":4\}/u)).toBeInTheDocument()
})
})

View file

@ -11,14 +11,14 @@ import {
type SortingState,
useReactTable,
} from '@tanstack/react-table'
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon, XIcon } from 'lucide-react'
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DevBadge } from '@/components/dev/DevBadge'
import { useQueryYieldgenReport, type EarnReportEntry } from '@/components/earn/report/hooks/useQueryYieldgenReport'
import { Alert, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Input } from '@/components/ui/input'
import { Balance } from '@/components/ui/jam/Balance'
import { SortIcon } from '@/components/ui/jam/SortIcon'
import { TablePagination } from '@/components/ui/jam/TablePagination'
@ -144,11 +144,11 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
}, [isShowAll, allEntries.length, table])
const visibleRows = table.getRowModel().rows
const exportRows = table.getSortedRowModel().rows
// Export visible entries as CSV
const downloadCsv = useCallback(() => {
const header = 'timestamp,cj_amount,input_count,input_amount,fee,earned,confirm_minutes,notes'
const rows = exportRows.map((row) =>
const rows = visibleRows.map((row) =>
[
row.original.timestamp.toISOString(),
row.original.cjTotalAmount ?? '',
@ -170,7 +170,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
setTimeout(() => {
URL.revokeObjectURL(url)
}, 0)
}, [exportRows])
}, [visibleRows])
const earnedTotal = useMemo(() => sumEarned(allEntries, BITCOIN_GENESIS_DATE), [allEntries])
@ -212,46 +212,23 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
<EarnReportChart entries={allEntries} />
{/* Toolbar: search + refresh */}
<div className="flex w-full flex-wrap items-center justify-end gap-2">
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<InputGroup className="min-w-[120px] flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<InputGroupInput
className="text-xs sm:text-sm"
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
placeholder={t('earn.report.placeholder_search')}
/>
<InputGroupAddon align="inline-start">
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => table.setGlobalFilter('')}
title={t('global.clear')}
className={globalFilter.length === 0 ? 'invisible' : undefined}
>
<XIcon />
</Button>
</InputGroupAddon>
</InputGroup>
<Button variant="outline" onClick={() => void refetch()} disabled={!enabled || isRefetching}>
<RefreshCwIcon className={isRefetching ? 'animate-spin' : undefined} />
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
<Button
className="hover:[&>svg]:motion-safe:animate-bounce"
variant="outline"
onClick={downloadCsv}
disabled={!enabled || allEntries.length === 0}
>
<DownloadIcon className="group/download" />
<span className="hidden sm:inline">{t('global.download')}</span>
</Button>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[12rem] flex-1">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder={t('earn.report.placeholder_search')}
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
className="pl-8"
/>
</div>
<Button variant="outline" size="icon" onClick={() => void refetch()} disabled={isRefetching}>
<RefreshCwIcon className={isRefetching ? 'animate-spin' : ''} />
</Button>
<Button variant="outline" size="sm" onClick={downloadCsv} disabled={allEntries.length === 0}>
<DownloadIcon />
{t('earn.report.text_button_download_csv')}
</Button>
<span className="text-muted-foreground ml-2 text-sm font-normal">
{globalFilter === ''
@ -259,7 +236,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
: t('earn.report.text_report_summary_filtered', { count: table.getFilteredRowModel().rows.length })}
</span>
{isDeveloperMode ? (
<Button className="min-w-0 shrink overflow-hidden" variant="outline" onClick={addDemoEntry}>
<Button variant="outline" size="sm" onClick={addDemoEntry}>
<PlusIcon />
{t('earn.report.text_button_generate_demo_report')}
<DevBadge />

View file

@ -4,7 +4,6 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DUMMY_SEED_PHRASE, pseudoRandomInteger } from '@/lib/utils'
import { flushActUpdates } from '@/test/flushActUpdates'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import type { BlockHeight } from '@/types/global'
import { ImportDetailsForm } from './ImportDetailsForm'
@ -65,26 +64,24 @@ describe('ImportDetailsForm', () => {
})
it('warns and does not submit when the blockheight is larger than current blockheight', async () => {
await withRuntimeLocale('en-US', async () => {
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
})
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
})
it('submits when all values are valid', async () => {

View file

@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'
import { Link, useNavigate, type NavigateFunction } from 'react-router-dom'
import { DevBadge } from '@/components/dev/DevBadge'
import { Button } from '@/components/ui/button'
import { Balance } from '@/components/ui/jam/Balance'
import { ThemeToggleButton } from '@/components/ui/jam/ThemeToggleButton'
import { Skeleton } from '@/components/ui/skeleton'
import type { SidebarContextProps } from '@/components/ui/use-sidebar'
@ -16,6 +15,7 @@ import type { RescanInfo } from '@/context/JamSessionInfoContext'
import { cn, shortenStringMiddle } from '@/lib/utils'
import type { AmountSats } from '@/types/global'
import { WithActivityIndicator } from '../ui/jam/ActivityIndicator'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'

View file

@ -77,9 +77,6 @@ vi.mock('@/hooks/useFeatures', () => ({
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
usePreviewFeatures: () => ({
'tx-history': true,
}),
}))
vi.mock('../dev/DevBadge', () => ({

View file

@ -6,7 +6,6 @@ import {
CurlyBracesIcon,
DownloadIcon,
HandCoinsIcon,
HistoryIcon,
LogsIcon,
MilkIcon,
NotebookTabsIcon,
@ -41,16 +40,14 @@ import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
import { routes } from '@/constants/routes'
import { useFeatures } from '@/hooks/useFeatures'
import { useDeveloperMode, usePreviewFeatures } from '@/store/jamSettingsStore'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { DevBadge } from '../dev/DevBadge'
import { Badge } from '../ui/badge'
export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>, 'side'>) {
const { t } = useTranslation()
const { toggleSidebar } = useSidebar()
const { enabled: isDeveloperMode } = useDeveloperMode()
const previewFeatures = usePreviewFeatures()
const { isFeatureEnabled } = useFeatures()
const mainItems = useMemo(
@ -97,18 +94,8 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
url: routes.walletJarsDetails,
icon: MilkIcon,
},
...(previewFeatures?.['tx-history'] !== true
? []
: [
{
title: t('sidebar.item_history.label'),
url: routes.txHistory,
icon: HistoryIcon,
preview: true,
},
]),
],
[t, previewFeatures],
[t],
)
const settingsItems = useMemo(
@ -193,7 +180,6 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
{item.preview ? <Badge variant="muted">{/* TODO: i18n */ 'Preview'}</Badge> : null}
</Link>
</SidebarMenuButton>
{item.subitems?.length && (

View file

@ -3,8 +3,8 @@ import { RefreshCwIcon, DownloadIcon, ArrowDownIcon, SearchIcon, XIcon } from 'l
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { cn, delayedPromise } from '@/lib/utils'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
interface LogViewerProps {
fileName: string
@ -143,43 +143,32 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
return (
<Card className="flex flex-1 flex-col overflow-hidden pb-0">
<CardHeader className="flex flex-col justify-center gap-4 md:flex-row md:items-center md:justify-between">
<CardTitle className="font-mono break-all break-keep select-all">{fileName}</CardTitle>
<CardHeader className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="font-mono break-all select-all">{fileName}</CardTitle>
{/* Search + action buttons */}
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<InputGroup className="min-w-[120px] flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<InputGroupInput
className="text-xs sm:text-sm"
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto sm:flex-nowrap">
<div className="relative min-w-0 flex-1 sm:max-w-[360px] sm:min-w-[220px]">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
className="h-9 pr-8 pl-8 text-xs"
placeholder={t('logs.placeholder_search')}
aria-label={t('logs.label_search')}
/>
<InputGroupAddon align="inline-start">
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{searchValue.length > 0 && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-1/2 right-1 h-7 w-7 -translate-y-1/2"
onClick={() => setSearchValue('')}
title={t('global.clear')}
className={searchValue.length === 0 ? 'invisible' : undefined}
>
<XIcon />
<XIcon className="h-4 w-4" />
</Button>
</InputGroupAddon>
</InputGroup>
<Button
variant="outline"
onClick={() => void handleRefresh()}
disabled={isLoadingRefresh}
title={t('global.refresh')}
>
<RefreshCwIcon className={isLoadingRefresh ? 'animate-spin' : undefined} />
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
)}
</div>
<Button
className="hover:[&>svg]:motion-safe:animate-bounce"
variant="outline"
@ -190,6 +179,15 @@ export function LogViewer({ fileName, value, refresh }: LogViewerProps) {
<DownloadIcon className="group/download" />
<span className="hidden sm:inline">{t('global.download')}</span>
</Button>
<Button
variant="outline"
onClick={() => void handleRefresh()}
disabled={isLoadingRefresh}
title={t('global.refresh')}
>
<RefreshCwIcon className={cn({ 'animate-spin': isLoadingRefresh })} />
<span className="hidden sm:inline">{t('global.refresh')}</span>
</Button>
</div>
</CardHeader>
{/* Search match count */}

View file

@ -4,7 +4,6 @@ import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStore } from 'zustand'
import { Alert } from '@/components/ui/alert'
import * as JAM from '@/constants/jam'
import { fetchLog } from '@/lib/api/jam'
import { getErrorReason } from '@/lib/errorReason'
import { authStore } from '@/store/authStore'
@ -39,7 +38,7 @@ export function useJmwalletdStdoutLog({ enabled = true }: UseJmwalletdStdoutLogP
refetchInterval: (query) => {
if (!enabled || token === undefined) return false
if (query.state.error) return false
return JAM.JMWALLETD_LOGS_POLLING_INTERVAL
return 2_500
},
// Keep previous data during refetch to prevent content flicker on slower networks.
placeholderData: (previousData) => previousData,

View file

@ -1,10 +1,9 @@
import { useState, type ComponentProps } from 'react'
import { LanguagesIcon, RefreshCwIcon, WalletIcon } from 'lucide-react'
import { RefreshCwIcon, WalletIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { LanguageSelectorDropdownMenu } from '@/components/ui/jam/LanguageSelector'
import { WalletLoadErrorAlert } from '@/components/ui/jam/WalletLoadErrorAlert'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
@ -72,36 +71,22 @@ export const LoginCard = ({
<>
<Card className="w-full max-w-md">
<CardHeader className="flex flex-col items-center space-y-2">
<div className="mb-4 grid w-full grid-cols-3">
<div></div>
<Button
type="button"
variant="default"
size="icon-xxl"
className="bg-primary/10 text-primary/80 hover:text-primary self-center justify-self-center rounded-full"
title={t('global.refresh')}
aria-label={t('global.refresh')}
onClick={() => void onReloadClick()}
disabled={listWalletsFetching}
>
{listWalletsFetching ? <Spinner className="size-6" /> : <WalletIcon className="size-5" />}
<span className="sr-only">{t('global.refresh')}</span>
</Button>
<LanguageSelectorDropdownMenu align="end">
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('settings.label_select_language_aria_label')}
title={t('settings.label_select_language_title')}
className="self-start justify-self-end"
className="text-primary h-10 w-10 rounded-full"
title={t('global.retry')}
aria-label={t('global.retry')}
onClick={() => void onReloadClick()}
>
<LanguagesIcon className="size-5" />
<span className="sr-only">{t('settings.label_select_language')}</span>
<WalletIcon />
</Button>
</LanguageSelectorDropdownMenu>
)}
</div>
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
{listWalletsLoading ? (
@ -118,7 +103,7 @@ export const LoginCard = ({
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-6">
<CardContent className="space-y-6">
{listWalletsError ? (
<>
<WalletLoadErrorAlert reason={getErrorReason(listWalletsError, t('global.errors.reason_unknown'))} />

View file

@ -18,7 +18,6 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Balance } from '@/components/ui/jam/Balance'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@ -37,6 +36,7 @@ import {
} from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { OrderbookChart } from './OrderbookChart'
import { OrderbookTable, type OrderTableEntry } from './OrderbookTable'
@ -87,7 +87,7 @@ const offerToTableEntry = (
maximumSize: String(offer.maxsize || 0),
bondValue: {
value: offer.fidelity_bond_value || 0,
displayValue: Math.floor(offer.fidelity_bond_value || 0).toLocaleString(),
displayValue: String((offer.fidelity_bond_value || 0).toFixed(0)),
locktime: fidelityBond?.locktime,
displayLocktime: fidelityBond?.locktime ? new Date(fidelityBond.locktime * 1_000).toDateString() : undefined,
displayExpiresIn: fidelityBond?.locktime

View file

@ -116,19 +116,16 @@ export const OrderbookTable = ({
columnHelper.accessor<'type', OrderTableEntry['type']>('type', {
header: () => <div className="flex items-center">{t('orderbook.table.heading_type')}</div>,
sortingFn: (a, b) => a.original.type.displayValue.localeCompare(b.original.type.displayValue),
cell: (info) => {
const value = info.getValue()
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={value.badgeColor}>{value.displayValue}</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{value.tooltip}</p>
</TooltipContent>
</Tooltip>
)
},
cell: (info) => (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={info.getValue().badgeColor}>{info.getValue().displayValue}</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{info.getValue().tooltip}</p>
</TooltipContent>
</Tooltip>
),
meta: {
align: 'center',
alphabetic: true,
@ -143,18 +140,12 @@ export const OrderbookTable = ({
}
return a.original.fee.value - b.original.fee.value
},
cell: ({
row: {
original: {
type: { isAbsolute },
fee,
},
},
}) => {
return isAbsolute ? (
<span className="slashed-zero tabular-nums">{fee.displayValue}</span>
cell: (info) => {
const entry = info.row.original
return entry.fee.displayValue.includes('%') ? (
<span className="font-mono">{entry.fee.displayValue}</span>
) : (
<Balance valueString={fee.displayValue} />
<Balance valueString={entry.fee.displayValue} />
)
},
meta: {
@ -193,29 +184,26 @@ export const OrderbookTable = ({
columnHelper.accessor('bondValue', {
header: () => <div className="flex items-center">{t('orderbook.table.heading_bond_value')}</div>,
sortingFn: (a, b) => a.original.bondValue.value - b.original.bondValue.value,
cell: ({
row: {
original: { bondValue },
},
}) => {
return bondValue.value > 0 ? (
cell: (info) => {
const entry = info.row.original
return entry.bondValue.value > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-help slashed-zero tabular-nums">{bondValue.displayValue}</span>
<span className="cursor-help">{entry.bondValue.displayValue}</span>
</TooltipTrigger>
<TooltipContent>
<div>
<Balance valueString={String(bondValue.amount || 0)} convertToUnit="btc" />
{bondValue.displayLocktime && (
<Balance valueString={String(entry.bondValue.amount || 0)} convertToUnit="btc" />
{entry.bondValue.displayLocktime && (
<div className="mt-1 text-xs">
{bondValue.displayLocktime} ({bondValue.displayExpiresIn})
{entry.bondValue.displayLocktime} ({entry.bondValue.displayExpiresIn})
</div>
)}
</div>
</TooltipContent>
</Tooltip>
) : (
<>{bondValue.displayValue}</>
<>{entry.bondValue.displayValue}</>
)
},
meta: {

View file

@ -19,8 +19,8 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
}))
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({ name, isSelected, onSelect }: { name: string; isSelected: boolean; onSelect: () => void }) => (
<button type="button" aria-pressed={isSelected} onClick={onSelect}>
SelectableJar: ({ name, isSelected, onClick }: { name: string; isSelected: boolean; onClick: () => void }) => (
<button type="button" aria-pressed={isSelected} onClick={onClick}>
{name}
</button>
),

View file

@ -91,7 +91,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
>
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<FieldLabel>{t('receive.label_source_jar')}</FieldLabel>
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{jars.map((jar, index) => (
<SelectableJar
key={index}
@ -102,7 +102,7 @@ export const ReceiveForm = ({ className, defaultValues, onSubmit, jars, disabled
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={values.source?.fromJar === jar.jarIndex}
onSelect={() => {
onClick={() => {
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
}}
disabled={disabled || isSubmitting}

View file

@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => {
return {
developerMode: false,
rescanning: false,
getAddress: vi.fn(),
defaultJars,
jars: defaultJars,
@ -108,12 +107,6 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
}),
}))
vi.mock('@/context/JamSessionInfoContext', () => ({
useRescanStatus: () => ({
rescanInfo: { rescanning: mocks.rescanning },
}),
}))
vi.mock('@/hooks/useApiClient', () => ({
useApiClient: () => ({}),
}))
@ -155,7 +148,6 @@ vi.mock('./ReceiveForm', () => ({
describe('ReceivePage', () => {
beforeEach(() => {
mocks.developerMode = false
mocks.rescanning = false
mocks.jars = mocks.defaultJars
mocks.getAddress.mockReset()
mocks.getAddress.mockResolvedValue({ data: { address: 'bc1qexample' } })
@ -183,17 +175,6 @@ describe('ReceivePage', () => {
await flushActUpdates()
})
it('prevents loading addresses while rescanning', () => {
mocks.rescanning = true
render(<ReceivePage walletFileName="wallet.jmdat" />)
expect(screen.getByRole('button', { name: 'receive.button_reveal_address' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'receive.button_new_address' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'receive.button_new_address' }))
expect(mocks.getAddress).not.toHaveBeenCalled()
})
it('uses a secondary jar badge when no jar is available', () => {
mocks.jars = []

View file

@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import PageTitle from '@/components/ui/jam/PageTitle'
import { Skeleton } from '@/components/ui/skeleton'
import { useRescanStatus } from '@/context/JamSessionInfoContext'
import { useJars } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { withMutationDelay } from '@/lib/queryClient'
@ -35,7 +34,6 @@ interface ReceivePageProps {
export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
const { t } = useTranslation()
const { jars } = useJars()
const { rescanInfo } = useRescanStatus()
const [selectedSourceJarIndex, setSelectedSourceJarIndex] = useState(jars.length > 0 ? jars[0].jarIndex : undefined)
const [amount, setAmount] = useState<AmountSats>()
@ -69,18 +67,17 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
mutationKey: ['receive', ...getaddressQueryKey(getAddressOptions)],
mutationFn: withMutationDelay(
async () => {
const sourceJarIndex = Number.parseInt(getAddressOptions.path.mixdepth, 10)
const { data } = await getaddress({
...getAddressOptions,
throwOnError: true,
})
return {
address: data.address,
sourceJarIndex,
sourceJarIndex: Number.parseInt(getAddressOptions.path.mixdepth, 10),
}
},
{
throttle: 210,
delayAfter: 21,
},
),
retry: false,
@ -110,7 +107,6 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
}
const fetchNewAddress = async () => {
if (rescanInfo.rescanning) return
await getAddressMutation.mutateAsync()
}
@ -136,7 +132,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
variant={jarButtonVariant(selectedSourceJarIndex)}
size="lg"
onClick={() => void fetchNewAddress()}
disabled={getAddressMutation.isPending || rescanInfo.rescanning}
disabled={getAddressMutation.isPending}
>
<HatGlassesIcon />
{t('receive.button_reveal_address')}
@ -193,11 +189,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => void fetchNewAddress()}
disabled={getAddressMutation.isPending || rescanInfo.rescanning}
>
<Button variant="outline" onClick={() => void fetchNewAddress()} disabled={getAddressMutation.isPending}>
{getAddressMutation.isPending ? (
<>
<RefreshCwIcon className="animate-spin motion-reduce:hidden" />

View file

@ -1,135 +0,0 @@
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { PaymentAttempt } from '@/context/JamSessionInfoContext'
import type { Jar } from '@/context/JamWalletInfoContext'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import { ActiveCollaborativeSendAlert } from './ActiveCollaborativeSendAlert'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <span>{i18nKey || children}</span>,
}))
vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({ addressChunkingEnabled: false }),
}))
const sampleJars: Jar[] = [
{
balanceSummary: {
calculatedAvailableBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 100_000,
},
color: '#e2b86a',
jarIndex: 0,
name: 'Jar Zero',
utxos: [],
},
{
balanceSummary: {
calculatedAvailableBalanceInSats: 50_000,
calculatedConfirmedAvailableBalanceInSats: 50_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 50_000,
},
color: '#3b5ba9',
jarIndex: 1,
name: 'Jar One',
utxos: [],
},
]
const sampleAttempt: PaymentAttempt = {
createdAt: 1_000,
utxosHashHex: bytesToHex(sha256(hexToBytes('00'))),
walletFileName: 'test.jmdat',
data: {
amount: { amount: 21_000, isSweep: false as const, sweepAmount: undefined },
destination: { address: 'bcrt1qdestinationaddress123456789', fromJar: undefined },
isCoinJoin: true,
numCollaborators: 5,
source: { fromJar: 0 as const },
txFee: { txFeeInBlocks: 3, txFeeInSatsPerVbyte: undefined, txFeeUnit: TX_FEE_UNITS.BLOCKS },
},
}
describe('ActiveCollaborativeSendAlert', () => {
it('renders title and details for active collaborative send to external address', () => {
render(
<ActiveCollaborativeSendAlert
paymentAttempt={sampleAttempt}
jars={sampleJars}
isAborting={false}
onAbort={vi.fn()}
/>,
)
expect(screen.getByText('send.text_coinjoin_already_running')).toBeInTheDocument()
expect(screen.getByText('Jar Zero')).toBeInTheDocument()
expect(screen.getByText('#0')).toBeInTheDocument()
expect(screen.getByText(/bcrt1qdestinationaddress123456789/u)).toBeInTheDocument()
expect(screen.getAllByText('5').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: 'global.abort' })).toBeInTheDocument()
})
it('renders destination jar badge when destination jar is present', () => {
const internalAttempt = {
...sampleAttempt,
data: {
...sampleAttempt.data,
destination: { address: 'bc1qinternal', fromJar: 1 as const },
},
}
render(
<ActiveCollaborativeSendAlert
paymentAttempt={internalAttempt}
jars={sampleJars}
isAborting={false}
onAbort={vi.fn()}
/>,
)
expect(screen.getByText('Jar One')).toBeInTheDocument()
expect(screen.getByText('#1')).toBeInTheDocument()
})
it('calls onAbort when abort button is clicked', async () => {
const user = userEvent.setup()
const onAbort = vi.fn()
render(
<ActiveCollaborativeSendAlert
paymentAttempt={sampleAttempt}
jars={sampleJars}
isAborting={false}
onAbort={onAbort}
/>,
)
await user.click(screen.getByRole('button', { name: 'global.abort' }))
expect(onAbort).toHaveBeenCalledTimes(1)
})
it('disables abort button when stopping coinjoin', () => {
render(
<ActiveCollaborativeSendAlert
paymentAttempt={sampleAttempt}
jars={sampleJars}
isAborting={true}
onAbort={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'global.abort' })).toBeDisabled()
})
})

View file

@ -1,144 +0,0 @@
import { useMemo } from 'react'
import { MilkIcon, UsersIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { Button } from '@/components/ui/button'
import { Address } from '@/components/ui/jam/Address'
import { Balance } from '@/components/ui/jam/Balance'
import { CookingPotIcon } from '@/components/ui/jam/CookingPotIcon'
import type { PaymentAttempt } from '@/context/JamSessionInfoContext'
import type { Jar } from '@/context/JamWalletInfoContext'
import { cn } from '@/lib/utils'
import { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemHeader, ItemTitle } from '../ui/item'
import { Label } from '../ui/label'
export interface ActiveCollaborativeSendAlertProps {
paymentAttempt?: PaymentAttempt
jars: Jar[]
isAborting: boolean
onAbort: () => void
}
export const ActiveCollaborativeSendAlert = ({
paymentAttempt,
jars,
isAborting,
onAbort,
}: ActiveCollaborativeSendAlertProps) => {
const { t } = useTranslation()
const data = paymentAttempt?.data
const sourceJar = useMemo(() => {
if (data?.source?.fromJar === undefined) return undefined
return jars.find((index) => index.jarIndex === data.source.fromJar)
}, [jars, data])
const destinationJar = useMemo(() => {
if (data?.destination?.fromJar === undefined) return undefined
return jars.find((index) => index.jarIndex === data.destination.fromJar)
}, [jars, data])
return (
<>
<Item
variant="outline"
className={cn({
//'ring-brand-success ring-1': value.status.completed,
//'ring-destructive ring-1': value.status.failed,
'ring-ring/50 ring-2': true,
})}
>
<ItemHeader className="mb-4">
<div>
<ItemTitle className="text-base">{t('send.text_coinjoin_already_running')}</ItemTitle>
<ItemDescription></ItemDescription>
</div>
</ItemHeader>
<ItemContent className="flex flex-col gap-2 md:flex-row md:gap-8">
<div className="col-span-full flex justify-center md:col-span-1">
<CookingPotIcon
className="animate-pulse"
sourceJarIndex={data?.source?.fromJar}
destinationJarIndex={data?.destination?.fromJar}
/>
</div>
{data && (
<>
<div className="grid grid-cols-1 gap-4">
{sourceJar ? (
<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">{t('send.confirm_send_modal.label_source_jar')}</Label>
<Badge variant={jarBadgeVariant(sourceJar.jarIndex)}>
{sourceJar.name} <span className="text-xs">#{sourceJar.jarIndex.toLocaleString()}</span>
</Badge>
</div>
</div>
) : null}
<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">
{t('send.confirm_send_modal.label_amount')}
{data.amount.isSweep ? (
<Badge variant="outline" className="text-xs">
Sweep
</Badge>
) : null}
</Label>
{data.amount.isSweep ? (
<Balance valueString={String(data.amount.sweepAmount)} />
) : (
<Balance valueString={String(data.amount.amount)} />
)}
</div>
</div>
{data.numCollaborators !== undefined ? (
<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">{t('send.confirm_send_modal.label_num_collaborators')}</Label>
{data.numCollaborators.toLocaleString()}
</div>
</div>
) : null}
<div className="col-span-full 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">{t('send.confirm_send_modal.label_recipient')}</Label>
{destinationJar && (
<Badge variant={jarBadgeVariant(destinationJar.jarIndex)}>
{destinationJar.name}{' '}
<span className="text-xs">#{destinationJar.jarIndex.toLocaleString()}</span>
</Badge>
)}
<Address
className="text-muted-foreground font-mono text-xs"
value={data.destination.address}
copyable={true}
/>
</div>
</div>
</div>
</>
)}
</ItemContent>
<ItemFooter>
<ItemActions>
<Button type="button" variant="destructive" onClick={onAbort} disabled={isAborting}>
{t('global.abort')}
</Button>
</ItemActions>
</ItemFooter>
</Item>
</>
)
}

View file

@ -1,14 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createJarSelectorDialogFormSchema } from './JarSelectorDialog.schema'
describe('createJarSelectorDialogFormSchema', () => {
const schema = createJarSelectorDialogFormSchema([0, 2])
it('accepts a selectable jar index', async () => {
await expect(schema.validate({ jarIndex: 2 })).resolves.toEqual({ jarIndex: 2 })
})
it.each([{ jarIndex: 1 }, { jarIndex: undefined }])('rejects an unavailable jar index', async (values) => {
await expect(schema.isValid(values)).resolves.toBe(false)
})
})

View file

@ -1,15 +0,0 @@
import * as yup from 'yup'
import type { JarIndex } from '@/types/global'
export type JarSelectorDialogFormValues = {
jarIndex: JarIndex
}
export const createJarSelectorDialogFormSchema = (
selectableJarIndexes: JarIndex[],
): yup.ObjectSchema<JarSelectorDialogFormValues> =>
yup
.object({
jarIndex: yup.number().oneOf(selectableJarIndexes).required(),
})
.required()

View file

@ -11,16 +11,16 @@ vi.mock('react-i18next', () => ({
vi.mock('../ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onSelect,
onClick,
disabled,
isSelected,
}: {
name?: string
onSelect?: () => void
onClick?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button type="button" onClick={onSelect} disabled={disabled} data-selected={isSelected}>
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),
@ -94,23 +94,20 @@ describe('JarSelectorDialog', () => {
expect(screen.queryByText('Pick a jar')).not.toBeInTheDocument()
})
it('disables the confirm button until a jar is selected', async () => {
it('disables the confirm button until a jar is selected', () => {
renderDialog()
const confirm = screen.getByRole('button', { name: 'modal.confirm_button_accept' })
const confirm = screen.getByText('modal.confirm_button_accept').closest('button')
expect(confirm).toBeDisabled()
fireEvent.click(screen.getByText('Jar 0'))
await waitFor(() => expect(confirm).not.toBeDisabled())
expect(confirm).not.toBeDisabled()
})
it('confirms the selected jar', async () => {
const onConfirm = vi.fn().mockResolvedValue(undefined)
renderDialog({ onConfirm })
fireEvent.click(screen.getByText('Jar 0'))
const confirm = screen.getByRole('button', { name: 'modal.confirm_button_accept' })
await waitFor(() => expect(confirm).not.toBeDisabled())
fireEvent.click(confirm)
fireEvent.click(screen.getByText('modal.confirm_button_accept'))
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(0))
await waitFor(() => expect(confirm).toBeDisabled())
})
it('closes and resets via the reject button', () => {

View file

@ -1,6 +1,4 @@
import { useMemo, type ComponentProps } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { useForm, useWatch } from 'react-hook-form'
import { useState, type ComponentProps } from 'react'
import { useTranslation } from 'react-i18next'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { BalanceSummary } from '@/lib/balanceSummary'
@ -9,7 +7,6 @@ import { Button } from '../ui/button'
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter, DialogHeader } from '../ui/dialog'
import { SelectableJar } from '../ui/jam/SelectableJar'
import { Spinner } from '../ui/spinner'
import { createJarSelectorDialogFormSchema, type JarSelectorDialogFormValues } from './JarSelectorDialog.schema'
type JarSelectorDialogProps = WithRequiredProperty<
Omit<ComponentProps<typeof Dialog>, 'children'>,
@ -36,100 +33,73 @@ export default function JarSelectorDialog({
}: JarSelectorDialogProps) {
const { t } = useTranslation()
const selectableJarIndexes = useMemo(
() => jars.filter((jar) => !disabledJars.includes(jar)).map((jar) => jar.jarIndex),
[disabledJars, jars],
)
const schema = useMemo(() => createJarSelectorDialogFormSchema(selectableJarIndexes), [selectableJarIndexes])
const {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting },
} = useForm<JarSelectorDialogFormValues>({
mode: 'onChange',
defaultValues: {
jarIndex: undefined,
},
resolver: yupResolver(schema),
})
const selectedJarIndex = useWatch({ control, name: 'jarIndex' })
const selectedJar = jars.find((jar) => jar.jarIndex === selectedJarIndex)
const canConfirm =
selectedJarIndex !== undefined && selectedJar !== undefined && selectableJarIndexes.includes(selectedJarIndex)
const [isConfirming, setIsConfirming] = useState(false)
const [selectedJar, setSelectedJar] = useState<Jar>()
const handleClose = () => {
reset()
setSelectedJar(undefined)
onOpenChange(false)
}
const doOnSubmit = handleSubmit(async ({ jarIndex }) => {
await onConfirm(jarIndex)
reset()
})
const confirm = async () => {
if (selectedJar === undefined) return
setIsConfirming(true)
try {
await onConfirm(selectedJar.jarIndex)
setSelectedJar(undefined)
} finally {
setIsConfirming(false)
}
}
return (
<Dialog open={open} onOpenChange={handleClose} {...dialogProps}>
<DialogContent className="sm:max-w-2xl">
<form onSubmit={(event) => void doOnSubmit(event)} className="grid gap-4" noValidate>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">{title}</DialogTitle>
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
</DialogHeader>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">{title}</DialogTitle>
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
</DialogHeader>
<div className="py-4">
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-2 gap-y-4">
{jars.map((jar) => (
<SelectableJar
key={jar.jarIndex}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={selectedJarIndex === jar.jarIndex}
onSelect={() =>
setValue('jarIndex', jar.jarIndex, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
disabled={disabledJars.includes(jar)}
/>
))}
</div>
<div className="py-4">
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-2 gap-y-4">
{jars.map((jar, index) => (
<SelectableJar
key={index}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={selectedJar === jar}
onClick={() => setSelectedJar(jar)}
disabled={disabledJars.includes(jar)}
/>
))}
</div>
</div>
<DialogFooter className="gap-3 sm:justify-center">
<Button
type="button"
className="min-h-12 flex-1 text-base"
variant="outline"
size="xxl"
onClick={handleClose}
>
{t('modal.confirm_button_reject')}
</Button>
<Button
type="submit"
className="min-h-12 flex-1 text-base"
size="xxl"
disabled={!canConfirm || isSubmitting}
>
{isSubmitting ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('modal.confirm_button_accept')}
</>
) : (
<>{t('modal.confirm_button_accept')}</>
)}
</Button>
</DialogFooter>
</form>
<DialogFooter className="gap-3 sm:justify-center">
<Button className="min-h-12 flex-1 text-base" variant="outline" size="xxl" onClick={handleClose}>
{t('modal.confirm_button_reject')}
</Button>
<Button
className="min-h-12 flex-1 text-base"
size="xxl"
onClick={() => void confirm()}
disabled={!selectedJar || isConfirming}
>
{isConfirming ? (
<>
<Spinner className="motion-reduce:hidden" />
{t('modal.confirm_button_accept')}
</>
) : (
<>{t('modal.confirm_button_accept')}</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)

View file

@ -27,7 +27,7 @@ vi.mock('../ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>address:{value}</span>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>balance:{valueString}</span>,
}))
@ -50,7 +50,6 @@ const sourceJar: Jar = {
calculatedAvailableBalanceInSats: 50_000,
calculatedTotalBalanceInSats: 50_000,
calculatedConfirmedAvailableBalanceInSats: 50_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#e2b86a',

View file

@ -3,7 +3,6 @@ import { InfoIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { Balance } from '@/components/ui/jam/Balance'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
@ -14,6 +13,7 @@ import { Button } from '../ui/button'
import { Card, CardContent, CardHeader } from '../ui/card'
import { Dialog, DialogTitle, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '../ui/dialog'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
import { Spinner } from '../ui/spinner'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { estimateMaxCollaboratorFee, useMiningFeeText, type EstimateMaxCollaboratorFeeResult } from './feeEstimate'

View file

@ -1,10 +1,84 @@
import { AlertTriangleIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
import { formatSats, shortenStringMiddle } from '@/lib/utils'
interface SendCoinjoinPreconditionAlertProps {
summary: SweepPreconditionSummary
}
export const SendCoinjoinPreconditionAlert = ({ summary }: SendCoinjoinPreconditionAlertProps) => {
return <PreconditionAlert summary={summary} i18nPrefix="send.coinjoin_precondition" />
const RetryLockedUtxoList = ({ summary }: SendCoinjoinPreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.retryLockedUtxos.length === 0) {
return null
}
return (
<>
<Trans
i18nKey="send.coinjoin_precondition.hint_missing_retries"
components={[
<></>,
<a key="retries-doc-link" href={JAM_JM_RETRIES_DOCS_URL} target="_blank" rel="noopener noreferrer" />,
]}
/>
<div className="mt-3 rounded-md border p-2">
<div className="mb-2 text-xs font-semibold">{t('global.utxos')}</div>
<ul className="space-y-1">
{summary.retryLockedUtxos.map((utxo) => (
<li key={utxo.utxo} className="bg-muted/50 rounded-sm px-2 py-1 font-mono text-xs">
<span className="mr-2 font-semibold">jar {utxo.mixdepth}</span>
<span className="mr-2">{formatSats(utxo.value)} sats</span>
<span>{shortenStringMiddle(utxo.utxo, 26)}</span>
</li>
))}
</ul>
</div>
</>
)
}
export const SendCoinjoinPreconditionAlert = ({ summary }: SendCoinjoinPreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.isFulfilled) {
return null
}
if (summary.numberOfMissingUtxos > 0) {
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertDescription>
{t('send.coinjoin_precondition.hint_missing_utxos', { minConfirmations: summary.options.minConfirmations })}
</AlertDescription>
</Alert>
)
}
if (summary.numberOfMissingConfirmations > 0) {
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertDescription>
{t('send.coinjoin_precondition.hint_missing_confirmations', {
minConfirmations: summary.options.minConfirmations,
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
})}
</AlertDescription>
</Alert>
)
}
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertDescription>
<RetryLockedUtxoList summary={summary} />
</AlertDescription>
</Alert>
)
}

View file

@ -1,10 +1,7 @@
import { Network } from 'bitcoin-address-validation'
import type { TFunction } from 'i18next'
import { describe, expect, it, vi } from 'vitest'
import { JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS, JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS } from '@/constants/jam'
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { toBalanceSummary } from '@/lib/balanceSummary'
import { TX_FEE_UNITS, toJamFeeConfigValues } from '@/lib/feeConfig'
import {
createSendFormSchema,
@ -13,58 +10,32 @@ import {
toSendFormDefaultValues,
} from './SendForm.schema'
const t = vi.fn((key: string, options?: Record<string, unknown>) =>
options ? `${key}:${JSON.stringify(options)}` : key,
) as unknown as TFunction<'translation', undefined>
const t = vi.fn((key: string) => key) as unknown as TFunction<'translation', undefined>
const validMainnetAddress = '1BitcoinEaterAddressDontSend8MUo1T'
const validRegtestAddress = 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'
const sourceJarAddress = '1BitcoinEaterAddressDontSendDHyNcX'
const utxo = (overrides: Partial<Utxo>): Utxo => ({
utxo: 'tx:0',
address: 'bcrt1qsource',
path: "m/84'/1'/0'/0/0",
label: '',
value: 100_000,
tries: 0,
tries_remaining: 3,
external: false,
mixdepth: 0,
confirmations: 6,
frozen: false,
locktime: undefined,
...overrides,
})
const fbUtxo = (overrides: Partial<Utxo>) =>
utxo({
locktime: '2999-01-01 00:00:00',
path: "m/84'/1'/0'/2/0:32503680000",
...overrides,
})
const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
({
jarIndex,
name: `Jar ${jarIndex}`,
color: '#808080',
balanceSummary: toBalanceSummary(utxos),
utxos,
}) as unknown as Jar
const validMainnetAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
const sourceJarAddress = '1CounterpartyXXXXXXXXXXXXXXXUWLpVr'
const jars = [
jar(0, [utxo({ value: 10_000 })]),
jar(1, []),
jar(2, [utxo({ value: 21_000, frozen: true })]),
jar(3, [
utxo({ value: 42_000 }),
fbUtxo({ utxo: 'non-frozen-fb:0', value: 21_000, frozen: false }),
fbUtxo({ utxo: 'non-frozen-fb:1', value: 29_000, frozen: false }),
]),
{
jarIndex: 0,
balanceSummary: {
calculatedAvailableBalanceInSats: 10_000,
},
},
{
jarIndex: 1,
balanceSummary: {
calculatedAvailableBalanceInSats: 0,
},
},
] as Jar[]
const addressSummary = {
[validMainnetAddress]: {
address: validMainnetAddress,
jarIndex: 2,
used: false,
},
[sourceJarAddress]: {
address: sourceJarAddress,
jarIndex: 0,
@ -110,48 +81,22 @@ describe('toSendFormDefaultValues', () => {
})
describe('createSendFormSchema', () => {
const schema = createSendFormSchema(
jars,
addressSummary,
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
Network.mainnet,
t,
)
const schema = createSendFormSchema(jars, addressSummary, 3, Network.mainnet, t)
it('accepts a valid coinjoin send', async () => {
await expect(schema.validate(validFormValues)).resolves.toMatchObject(validFormValues)
})
it('rejects source jars with zero balance', async () => {
it('rejects unavailable source jars', async () => {
await expect(
schema.validate({
...validFormValues,
source: { fromJar: jars[1].jarIndex },
source: { fromJar: 1 },
amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT },
}),
).rejects.toThrow('send.feedback_invalid_source_jar')
})
it('rejects source jars without unfrozen balance', async () => {
await expect(
schema.validate({
...validFormValues,
source: { fromJar: jars[2].jarIndex },
amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT },
}),
).rejects.toThrow('send.feedback_invalid_source_jar_must_unfreeze_utxos')
})
it('rejects source jars without unfrozen fidelity bonds', async () => {
await expect(
schema.validate({
...validFormValues,
source: { fromJar: jars[3].jarIndex },
amount: { isSweep: true, sweepAmount: MIN_SEND_AMOUNT },
}),
).rejects.toThrow('send.feedback_invalid_source_jar_must_freeze_fidelity_bond:{"count":2}')
})
it('rejects reused or source-jar destination addresses', async () => {
await expect(
schema.validate({
@ -186,30 +131,13 @@ describe('createSendFormSchema', () => {
destination: { address: sourceJarAddress },
}),
).rejects.toThrow('send.feedback_address_from_source_jar')
await expect(
schema.validate({
...validFormValues,
destination: { address: validRegtestAddress },
}),
).rejects.toThrow('send.feedback_destination_network_mismatch')
await expect(
schema.validate({
...validFormValues,
destination: { address: 'invalid' },
}),
).rejects.toThrow('send.feedback_invalid_destination_address')
})
it('rejects invalid amounts and clears collaborators for direct sends', async () => {
await expect(
schema.validate({
...validFormValues,
amount: {
isSweep: false,
amount: jars[validFormValues.source.fromJar].balanceSummary.calculatedTotalBalanceInSats + 1,
},
amount: { isSweep: false, amount: 11_000 },
}),
).rejects.toThrow('send.feedback_amount_exceeds_balance')
@ -217,7 +145,7 @@ describe('createSendFormSchema', () => {
schema.validate({
...validFormValues,
isCoinJoin: false,
numCollaborators: 10,
numCollaborators: 4,
}),
).resolves.toMatchObject({
isCoinJoin: false,
@ -225,29 +153,6 @@ describe('createSendFormSchema', () => {
})
})
it('rejects invalid number of collaborators', async () => {
await expect(
schema.validate({
...validFormValues,
numCollaborators: JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS - 1,
}),
).rejects.toThrow('send.error_invalid_num_collaborators:{"minNumCollaborators":4,"maxNumCollaborators":20}')
await expect(
schema.validate({
...validFormValues,
numCollaborators: JAM_SWEEP_MAX_MAX_NUMBER_OF_COLLABORATORS + 1,
}),
).rejects.toThrow('send.error_invalid_num_collaborators:{"minNumCollaborators":4,"maxNumCollaborators":20}')
await expect(
schema.validate({
...validFormValues,
numCollaborators: null,
}),
).rejects.toThrow('send.error_invalid_num_collaborators:{"minNumCollaborators":4,"maxNumCollaborators":20}')
})
it('supports sweep sends with a sweep amount', async () => {
await expect(
schema.validate({

View file

@ -5,7 +5,6 @@ import { MAX_NUM_COLLABORATORS, TOTAL_COIN_SUPPLY } from '@/constants/jam'
import type { AddressSummary, Jar } from '@/context/JamWalletInfoContext'
import { TX_FEE_UNITS } from '@/lib/feeConfig'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
import * as fb from '@/lib/fidelityBondUtils'
import { destinationAddressField, sourceJarField } from '@/lib/formValidation'
import { isValidInteger, pseudoRandomInteger } from '@/lib/utils'
import type { AmountSats } from '@/types/global'
@ -22,8 +21,6 @@ export const initialNumberOfCollaborators = (minValue: number): number => {
export const MIN_SEND_AMOUNT: AmountSats = 292
export const MAX_SEND_AMOUNT: AmountSats = TOTAL_COIN_SUPPLY
export const ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL = 'valid-source-jar-needs-manual-coin-control-test'
const FORM_INPUT_DEFAULT_VALUES: Partial<SendFormValues> = {
source: undefined,
destination: undefined,
@ -67,28 +64,11 @@ export const createSendFormSchema = (
.object({
source: yup
.object({
fromJar: sourceJarField(t('send.feedback_invalid_source_jar'), (jarIndex) =>
jars.some((it) => it.jarIndex === jarIndex),
)
.test(
ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL,
t('send.feedback_invalid_source_jar_must_unfreeze_utxos'),
(jarIndex) => {
const jar = jars.find((it) => it.jarIndex === jarIndex)
if (!jar) return true
if (jar.balanceSummary.calculatedAvailableBalanceInSats > 0) return true
return jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0
},
)
.test(
'valid-source-jar-has-funds-test',
t('send.feedback_invalid_source_jar_no_available_utxos'),
(jarIndex) => {
const jar = jars.find((it) => it.jarIndex === jarIndex)
if (!jar) return true
return jar.balanceSummary.calculatedAvailableBalanceInSats > 0
},
),
fromJar: sourceJarField(
t('send.feedback_invalid_source_jar'),
(jarIndex) =>
(jars.find((it) => it.jarIndex === jarIndex)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0,
),
})
.required(),
destination: yup
@ -157,26 +137,7 @@ export const createSendFormSchema = (
// eslint-disable-next-line unicorn/prefer-spread -- false positive
.concat(createTxFeeFormSchema({ t }))
.required()
.test((root) => {
const jar = jars.find((it) => it.jarIndex === root.source.fromJar)
const unfrozenFidelityBondOutputs = (jar?.utxos ?? [])
.filter((it) => fb.utxo.isFidelityBond(it))
.filter((it) => it.frozen !== true)
if (unfrozenFidelityBondOutputs.length === 0) return true
const errorMessage = t('send.feedback_invalid_source_jar_must_freeze_fidelity_bond', {
count: unfrozenFidelityBondOutputs.length,
})
return new yup.ValidationError(
errorMessage,
root.destination.address,
'source.fromJar',
ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL,
true,
)
})
.test((root) => {
.test('address-not-from-source-jar-test', function (root) {
// Note: `fromJar` might still be `undefined` at this point
if (root.source.fromJar === undefined) return true
const addressIsFromSourceJar = addressSummary[root.destination.address]?.jarIndex === root.source.fromJar
@ -184,15 +145,9 @@ export const createSendFormSchema = (
const errorMessage = t('send.feedback_address_from_source_jar')
return new yup.ValidationError(
errorMessage,
root.destination.address,
'destination.address',
'address-not-from-source-jar-test',
true,
)
return new yup.ValidationError(errorMessage, root.destination.address, 'destination.address', undefined, true)
})
.test((root) => {
.test('amount-exceeds-balance-test', function (root) {
if (root.amount.isSweep) return true
if (root.amount.amount === undefined || root.amount.amount === null) return true
@ -206,7 +161,7 @@ export const createSendFormSchema = (
t('send.feedback_amount_exceeds_balance'),
root.amount.amount,
'amount.amount',
'amount-exceeds-balance-test',
undefined,
true,
)
})

View file

@ -18,22 +18,9 @@ const h = vi.hoisted(() => {
},
toastSuccess: vi.fn<(message: string) => void>(),
toastError: vi.fn<(message: string) => void>(),
hasOrders: true,
orderbookIsLoading: false,
orderbookError: false,
}
})
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: () => ({
hasOrders: h.hasOrders,
queryResult: {
isLoading: h.orderbookIsLoading,
isError: h.orderbookError,
},
}),
}))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
@ -46,21 +33,11 @@ vi.mock('sonner', () => ({
}))
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.stubGlobal(
'ResizeObserver',
class ResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
},
)
vi.mock('@/context/JamWalletInfoContext', () => ({
useDetectNetwork: () => ({ network: 'mainnet' }),
}))
@ -78,13 +55,13 @@ vi.mock('../ui/jam/Address', () => ({
Address: () => <div data-testid="address" />,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
vi.mock('../ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <div data-testid="balance">{valueString}</div>,
}))
vi.mock('../ui/jam/SelectableJar', () => ({
SelectableJar: ({ name, onSelect, disabled }: { name?: string; onSelect?: () => void; disabled?: boolean }) => (
<button onClick={onSelect} disabled={disabled}>
SelectableJar: ({ name, onClick, disabled }: { name?: string; onClick?: () => void; disabled?: boolean }) => (
<button onClick={onClick} disabled={disabled}>
{name}
</button>
),
@ -179,9 +156,6 @@ describe('SendForm', () => {
h.getaddressResult = { data: { address: h.DEFAULT_NEW_DUMMY_ADDRESS_0 }, error: undefined }
h.toastSuccess = vi.fn<(message: string) => void>()
h.toastError = vi.fn<(message: string) => void>()
h.hasOrders = true
h.orderbookIsLoading = false
h.orderbookError = false
})
it('renders the core fields', () => {
@ -312,7 +286,7 @@ describe('SendForm', () => {
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('global.errors.error_loading_address_failed'))
})
it('applies a pasted bip21 uri and shows success toast', async () => {
it('applies a pasted bip21 uri', async () => {
renderForm()
const inputBefore = document.querySelector('#send-destination') as HTMLInputElement
@ -329,7 +303,7 @@ describe('SendForm', () => {
await flushActUpdates()
})
it('ignores a non-bitcoin-uri paste', () => {
it('ignores a non-bitcoin paste', () => {
renderForm()
const input = document.querySelector('#send-destination') as HTMLInputElement
@ -338,18 +312,6 @@ describe('SendForm', () => {
expect(h.toastSuccess).not.toHaveBeenCalled()
})
it('applies pasted address and does show success toast', () => {
renderForm()
const inputBefore = document.querySelector('#send-destination') as HTMLInputElement
fireEvent.paste(inputBefore, { clipboardData: { getData: () => h.DEFAULT_SCAN_DUMMY_ADDRESS_1 } })
const inputAfter = document.querySelector('#send-destination') as HTMLInputElement
expect(inputAfter).toHaveValue(h.DEFAULT_SCAN_DUMMY_ADDRESS_1)
expect(h.toastSuccess).not.toHaveBeenCalled()
})
it('renders the debug panel when debug is enabled', () => {
renderForm({ debug: true })
expect(screen.getByText('isValid:')).toBeInTheDocument()
@ -394,49 +356,4 @@ describe('SendForm', () => {
await flushActUpdates()
})
it('shows empty orderbook warning when collaborative send is selected and orderbook is empty', () => {
h.hasOrders = false
renderForm()
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
})
it('does not show empty orderbook warning when orderbook is loading or has error', () => {
h.hasOrders = false
h.orderbookIsLoading = true
const { rerender } = renderForm()
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
h.orderbookIsLoading = false
h.orderbookError = true
rerender(
<SendForm
onSubmit={vi.fn()}
walletFileName="test.jmdat"
jars={mockJars}
walletBalanceSummary={mockBalanceSummary}
addressSummary={mockAddressSummary}
feeConfigValues={mockFeeConfigValues}
/>,
)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
})
it('does not show empty orderbook warning when non-collaborative send is selected', () => {
h.hasOrders = false
renderForm()
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
const optionsAccordion = screen.getByRole('button', { name: /send.sending_options/i })
fireEvent.click(optionsAccordion)
const collaborativeSwitch = document.querySelector('#switch-is-collaborative-transaction')
expect(collaborativeSwitch).toBeInTheDocument()
fireEvent.click(collaborativeSwitch!)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
})
})

View file

@ -5,16 +5,15 @@ import { getAddressInfo, Network } from 'bitcoin-address-validation'
import type { AddressInfo } from 'bitcoin-address-validation'
import { AlertTriangleIcon, BrushCleaningIcon, MilkIcon, ScanQrCodeIcon, XIcon } from 'lucide-react'
import { FormProvider, useForm, useWatch } from 'react-hook-form'
import type { FieldErrors, Resolver, SubmitHandler } from 'react-hook-form'
import type { Resolver, SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import QrScannerDialog from '@/components/ui/QrScannerDialog'
import { Balance } from '@/components/ui/jam/Balance'
import { isDevMode } from '@/constants/debugFeatures'
import { MAX_NUM_COLLABORATORS } from '@/constants/jam'
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 { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import type { BalanceSummary } from '@/lib/balanceSummary'
import { parseBip21Uri, type Bip21ParseResult } from '@/lib/bip21'
import type { JamFeeConfigValues } from '@/lib/feeConfig'
@ -32,8 +31,8 @@ import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
import { Input } from '../ui/input'
import { inputVariants } from '../ui/input-variants'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
import { SatSymbol } from '../ui/jam/CurrencySymbol'
import { OrderbookEmptyAlert } from '../ui/jam/OrderbookEmptyAlert'
import { SelectableJar } from '../ui/jam/SelectableJar'
import { Slider } from '../ui/slider'
import { Spinner } from '../ui/spinner'
@ -95,6 +94,12 @@ const AddressFromJarSelectorDialog = ({
)
}
// set the default to one collaborator in dev mode
const DEV_INITIAL_NUM_COLLABORATORS_INPUT = 1
// TODO: this value should be dynamic via jm backend settings
const MIN_NUM_COLLABORATORS = isDevMode() ? DEV_INITIAL_NUM_COLLABORATORS_INPUT : JM_MINIMUM_MAKERS_DEFAULT
const FieldPrefixSatSymbol = (
<SatSymbol
width={'18px'}
@ -109,8 +114,7 @@ interface SendFormProps {
className?: string
onSubmit: SubmitHandler<SendFormValues>
onSourceJarChange?: (jarIndex: JarIndex | undefined) => void
onSourceJarClicked?: (jarIndex: JarIndex | undefined) => void
sourceJarLabelButton?: (errors?: FieldErrors<{ fromJar: number }>) => React.ReactElement
sourceJarLabelButton?: React.ReactElement
minNumberOfCollaborators?: number
feeConfigValues: JamFeeConfigValues
walletFileName: WalletFileName
@ -125,12 +129,11 @@ export function SendForm({
className,
onSubmit,
onSourceJarChange,
onSourceJarClicked,
sourceJarLabelButton,
disabled,
feeConfigValues,
walletFileName,
minNumberOfCollaborators = JM_MINIMUM_MAKERS_DEFAULT,
minNumberOfCollaborators = MIN_NUM_COLLABORATORS,
jars,
walletBalanceSummary,
addressSummary,
@ -184,16 +187,6 @@ export function SendForm({
const isCoinJoin = useWatch({ control, name: 'isCoinJoin' })
const collaboratorCount = useWatch({ control, name: 'numCollaborators' })
const {
hasOrders,
queryResult: {
isLoading: orderbookCheckIsLoading,
isFetching: orderbookCheckIsFetching,
isError: orderbookCheckIsError,
refetch: orderbookRefetch,
},
} = useQueryOrderbook()
const destinationAddressInfo = useMemo(() => {
try {
return destinationAddress !== undefined ? getAddressInfo(destinationAddress) : undefined
@ -211,15 +204,6 @@ export function SendForm({
onSourceJarChange?.(sourceJarIndex)
}, [onSourceJarChange, sourceJarIndex])
useEffect(() => {
if (formState.dirtyFields.source?.fromJar) {
console.debug(
'Validating `source.fromJar` as wallet balance summary changed (e.g. frozen/unfrozen utxos, new transaction, etc.)',
)
void trigger('source.fromJar')
}
}, [walletBalanceSummary, trigger, formState.dirtyFields.source?.fromJar])
const destinationJar = useMemo(() => {
if (destinationJarIndex === undefined) return
return jars.find((it) => it.jarIndex === destinationJarIndex)
@ -275,9 +259,7 @@ export function SendForm({
event.preventDefault()
applyBip21Result(parsed)
if (parsed.fromUri) {
toast.success(t('send.qr_scan_bip21_applied'))
}
toast.success(t('send.qr_scan_bip21_applied'))
},
[applyBip21Result, t],
)
@ -316,67 +298,47 @@ export function SendForm({
{...sendFormMethods}
>
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<div className="space-y-4">
<Field className="space-y-4" data-invalid={errors.source?.fromJar !== undefined}>
<div className="space-y-2">
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
<div className="flex flex-wrap items-center justify-between gap-2">
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
{sourceJarLabelButton?.(errors.source)}
{sourceJarLabelButton && <>{sourceJarLabelButton}</>}
</div>
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
{jars.map((jar, index) => {
const noFunds =
jar.balanceSummary.calculatedAvailableBalanceInSats <= 0 &&
jar.balanceSummary.calculatedAvailableFrozenBalanceInSats <= 0
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-8">
{jars.map((jar, index) => (
<SelectableJar
key={index}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={sourceJarIndex === jar.jarIndex}
onClick={() => {
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
const isSelected = sourceJarIndex === jar.jarIndex
return (
<SelectableJar
key={index}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
onSourceJarClicked?.(sourceJarIndex)
}
}}
onSelect={() => {
setValue('source.fromJar', jar.jarIndex, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
})
if (isSweep === true) {
setValue('amount.isSweep', false, { shouldValidate: true })
setValue('amount.sweepAmount', undefined, { shouldValidate: true })
setValue('amount.amount', undefined, { shouldValidate: true })
}
if (destinationJarIndex === jar.jarIndex) {
setValue('destination.address', '', { shouldValidate: true })
setValue('destination.fromJar', undefined, { shouldValidate: true })
} else if (destinationAddress !== undefined) {
void trigger('destination.address')
}
}}
disabled={disabled || noFunds}
/>
)
})}
if (isSweep === true) {
setValue('amount.isSweep', false, { shouldValidate: true })
setValue('amount.sweepAmount', undefined, { shouldValidate: true })
setValue('amount.amount', undefined, { shouldValidate: true })
}
if (destinationJarIndex === jar.jarIndex) {
setValue('destination.address', '', { shouldValidate: true })
setValue('destination.fromJar', undefined, { shouldValidate: true })
} else if (destinationAddress !== undefined) {
void trigger('destination.address')
}
}}
disabled={disabled || jar.balanceSummary.calculatedAvailableBalanceInSats <= 0}
/>
))}
</div>
{errors.source?.fromJar?.message ? <FieldError>{errors.source?.fromJar.message}</FieldError> : null}
</Field>
{hasCoinjoinPreconditionWarning && coinjoinPreconditionSummary && (
<SendCoinjoinPreconditionAlert summary={coinjoinPreconditionSummary} />
)}
{isCoinJoin && !orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && (
<OrderbookEmptyAlert isChecking={orderbookCheckIsFetching} onCheckClick={orderbookRefetch} />
)}
</div>
<div className="space-y-2">

View file

@ -26,7 +26,6 @@ const mocks = vi.hoisted(() => ({
stopCoinjoinRefetch: vi.fn(),
takerRunning: false,
schedulerRunning: false,
makerRunning: false,
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
@ -34,9 +33,6 @@ const mocks = vi.hoisted(() => ({
walletInfoIsFetching: false,
walletInfoIsLoading: false,
waitForUtxosToBeSpent: [] as string[],
hasOrders: true,
orderbookIsLoading: false,
orderbookError: null as Error | null,
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
@ -76,23 +72,11 @@ vi.mock('@tanstack/react-query', () => ({
}))
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) =>
values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
}),
}))
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: () => ({
hasOrders: mocks.hasOrders,
queryResult: {
isLoading: mocks.orderbookIsLoading,
error: mocks.orderbookError,
},
}),
}))
vi.mock('sonner', () => ({
toast: {
error: mocks.toastError,
@ -161,10 +145,6 @@ vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: true }),
}))
vi.mock('@/context/JamDisplayContext', () => ({
useJamDisplayContext: () => ({ addressChunkingEnabled: false }),
}))
vi.mock('@/context/JamSessionInfoContext', () => ({
useJamSessionInfoContext: () => ({
clearCurrentPaymentAttempt: mocks.clearCurrentPaymentAttempt,
@ -177,9 +157,6 @@ vi.mock('@/context/JamSessionInfoContext', () => ({
running: mocks.schedulerRunning,
},
},
makerInfo: {
running: mocks.makerRunning,
},
}),
}))
@ -232,11 +209,11 @@ vi.mock('./SendForm', () => ({
disabled?: boolean
onSourceJarChange?: (jarIndex: number | undefined) => void
onSubmit: (values: SendFormValues) => void
sourceJarLabelButton?: () => React.ReactElement
sourceJarLabelButton?: React.ReactElement
}) => (
<div>
send-form:{String(disabled)}
<div>{sourceJarLabelButton?.()}</div>
<div>{sourceJarLabelButton}</div>
<button
onClick={() => {
onSourceJarChange?.(0)
@ -264,7 +241,6 @@ vi.mock('./UtxoSelectionDialog', () => ({
const balanceSummary = {
calculatedAvailableBalanceInSats: 20_000,
calculatedConfirmedAvailableBalanceInSats: 20_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedTotalBalanceInSats: 20_000,
}
@ -346,9 +322,6 @@ describe('SendPage', () => {
mocks.walletInfoIsFetching = false
mocks.walletInfoIsLoading = false
mocks.waitForUtxosToBeSpent = []
mocks.hasOrders = true
mocks.orderbookIsLoading = false
mocks.orderbookError = null
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
@ -536,7 +509,15 @@ describe('SendPage', () => {
})
it('shows the maker running warning', async () => {
mocks.makerRunning = true
jmSessionStore.setState({
state: {
coinjoin_in_process: false,
maker_running: true,
session: true,
wallet_name: 'wallet.jmdat',
rescanning: false,
},
})
render(<SendPage walletFileName="wallet.jmdat" />)

View file

@ -20,7 +20,6 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import * as JAM from '@/constants/jam'
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
import {
useAddressSummary,
@ -35,9 +34,8 @@ import type { UtxoId } from '@/hooks/useQueryUtxos'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import { useUtxoSelectionDialog } from '@/hooks/useUtxoSelectionDialog'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withMutationDelay } from '@/lib/queryClient'
import { cn, scrollToTop, type WalletFileName } from '@/lib/utils'
import { scrollToTop, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import { jmTxStore, type JmTxInfo } from '@/store/jmTxStore'
@ -45,11 +43,9 @@ import type { JarIndex } from '@/types/global'
import { Button } from '../ui/button'
import { Card, CardContent } from '../ui/card'
import { Spinner } from '../ui/spinner'
import { ActiveCollaborativeSendAlert } from './ActiveCollaborativeSendAlert'
import { PaymentAbortDialog } from './PaymentAbortDialog'
import PaymentConfirmDialog from './PaymentConfirmDialog'
import { SendForm } from './SendForm'
import { ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL } from './SendForm.schema'
import { UtxoSelectionDialog } from './UtxoSelectionDialog'
import { buildCollaborativeSendRequest, buildNonCollaborativeSendRequest } from './collaborativeSend'
import type { SendFormValues } from './types'
@ -85,9 +81,8 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
waitForUtxosToBeSpent,
setWaitForUtxosToBeSpent,
} = useJamWalletInfoContext()
const jmSessionActive = useStore(jmSessionStore, (state) => state.state?.session)
const jmSession = useStore(jmSessionStore, (state) => state.state)
const {
makerInfo: { running: makerRunning },
takerInfo: {
running: takerRunning,
currentPaymentAttempt,
@ -119,20 +114,15 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
return jars.find((it) => it.jarIndex === sourceJarIndex)
}, [jars, sourceJarIndex])
const { onOpenUtxoSelector, ...utxoSelectionDialog } = useUtxoSelectionDialog({
const utxoSelectionDialog = useUtxoSelectionDialog({
walletFileName,
sourceJar,
addressSummary,
})
const availableUtxosForPayment = useMemo(() => {
const isCoinJoin = sendFromValuesAwaitingConfirmation?.isCoinJoin ?? true // assume true
return (sourceJar?.utxos || [])
.filter((utxo) => !utxo.frozen)
.filter((utxo) => !fb.utxo.isLocked(utxo))
.filter((utxo) => (isCoinJoin ? true : !fb.utxo.isFidelityBond(utxo)))
.toSorted((a, b) => a.confirmations - b.confirmations)
}, [sourceJar, sendFromValuesAwaitingConfirmation?.isCoinJoin])
return (sourceJar?.utxos || []).filter((utxo) => !utxo.frozen).toSorted((a, b) => a.confirmations - b.confirmations)
}, [sourceJar])
const destinationJar = useMemo(() => {
const destinationJarIndex = sendFromValuesAwaitingConfirmation?.destination?.fromJar
@ -207,14 +197,14 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
useRefreshSession({
enabled: isWaitingCoinjoinStart || isWaitingCoinjoinStop,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
refetchInterval: 3_000,
refetchDelay: 1_000,
})
useRefreshSession({
enabled: takerRunning,
refetchInterval: JAM.RUNNING_COINJOIN_POLLING_INTERVAL,
refetchDelay: JAM.RUNNING_COINJOIN_POLLING_DELAY,
refetchInterval: 5_000,
refetchDelay: 1_000,
})
useEffect(() => {
@ -371,7 +361,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
await stopCoinjoinMutationMutateAsync()
}
if (!jmSessionActive || walletInfoIsLoading) {
if (!jmSession || walletInfoIsLoading) {
return <PageLoading />
}
@ -413,7 +403,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{makerRunning === true && (
{jmSession?.maker_running === true && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
@ -449,77 +439,102 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
* If data `takerCurrentAttempt` is not present, no message is shown
* when the taker service stops - this is not ideal, but okay.
*/
currentPaymentAttempt?.data.isCoinJoin === true && !isWaitingCoinjoinStart && takerRunning === false && (
<>
{walletInfoIsFetching ? (
<>
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_awaiting_completion')}</AlertTitle>
</Alert>
</>
) : (
<>
{currentPaymentAttempt.utxosHashHex === utxosHashHex ? (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('send.alert_collaborative_ended_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_ended_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
currentPaymentAttempt !== undefined &&
currentPaymentAttempt.data.isCoinJoin &&
!isWaitingCoinjoinStart &&
takerRunning === false && (
<>
{walletInfoIsFetching ? (
<>
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('send.alert_collaborative_awaiting_completion')}</AlertTitle>
</Alert>
) : (
<Alert variant="success">
<CheckCircle2Icon />
<AlertTitle>{t('send.alert_collaborative_completed_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_completed_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
setFormId((current) => current + 1)
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</>
)}
</>
)
</>
) : (
<>
{currentPaymentAttempt.utxosHashHex === utxosHashHex ? (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('send.alert_collaborative_ended_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_ended_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
) : (
<Alert variant="success">
<CheckCircle2Icon />
<AlertTitle>{t('send.alert_collaborative_completed_title')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<div>{t('send.alert_collaborative_completed_description')}</div>
<div>
<Button
variant="outline"
onClick={() => {
setFormId((current) => current + 1)
clearCurrentPaymentAttempt()
}}
>
{t('global.done')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</>
)}
</>
)
}
{schedulerRunning && (
{takerRunning && !isWaitingCoinjoinStop && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
{currentPaymentAttempt && (
<pre className="max-w-full overflow-x-auto">
{JSON.stringify(
{
sourceJar: currentPaymentAttempt.data.source.fromJar,
destinationJar: currentPaymentAttempt.data.destination.fromJar,
destinationAddress: currentPaymentAttempt.data.destination.address,
isSweep: currentPaymentAttempt.data.amount.isSweep === true,
amount:
currentPaymentAttempt.data.amount.isSweep === true
? currentPaymentAttempt.data.amount.sweepAmount
: currentPaymentAttempt.data.amount.amount,
numCollaborators: currentPaymentAttempt.data.numCollaborators,
},
null,
2,
)}
</pre>
)}
{takerRunning && !schedulerRunning ? (
<div>
<Button
variant="outline"
onClick={() => setShowAbortCoinjoinDialog(true)}
disabled={isWaitingCoinjoinStop}
>
{t('global.abort')}
</Button>
</div>
) : null}
</AlertDescription>
</Alert>
)}
{takerRunning && !schedulerRunning && (
<ActiveCollaborativeSendAlert
paymentAttempt={currentPaymentAttempt?.data.isCoinJoin === true ? currentPaymentAttempt : undefined}
jars={jars}
isAborting={isWaitingCoinjoinStop}
onAbort={() => setShowAbortCoinjoinDialog(true)}
/>
)}
{triggerNonCollaborativeTransaction.error ? (
<Alert variant="destructive">
<AlertTriangleIcon />
@ -564,11 +579,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
</>
)}
<Card
className={cn('transition-all duration-500', {
'blur-[2px]': currentPaymentAttempt !== undefined || takerRunning || schedulerRunning || makerRunning,
})}
>
<Card>
<CardContent>
<SendForm
key={formId}
@ -580,7 +591,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
addressSummary={addressSummary}
walletBalanceSummary={walletBalanceSummary}
disabled={
makerRunning ||
jmSession?.maker_running === true ||
takerRunning ||
rescanInfo.rescanning ||
isWaitingCoinjoinStart ||
@ -593,23 +604,17 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
}
debug={isDeveloperMode}
onSourceJarChange={setSourceJarIndex}
onSourceJarClicked={onOpenUtxoSelector}
sourceJarLabelButton={(errors) => {
const errorSolvedByUtxoSelection = errors?.fromJar?.type === ERROR_SOURCE_JAR_NEEDS_MANUAL_COIN_CONTROL
const animate = utxoSelectionDialog.dialogProps.open === false && errorSolvedByUtxoSelection
return (
<Button
type="button"
variant="outline"
className={animate ? 'animate-shake' : undefined}
disabled={utxoSelectionDialog.utxoSelectorDisabled}
onClick={onOpenUtxoSelector}
>
<ListFilterIcon className={animate ? 'motion-safe:animate-bounce' : undefined} />
{t('show_utxos.text_select_utxos_tooltip')}
</Button>
)
}}
sourceJarLabelButton={
<Button
type="button"
variant="outline"
disabled={utxoSelectionDialog.utxoSelectorDisabled}
onClick={utxoSelectionDialog.onOpenUtxoSelector}
>
<ListFilterIcon />
{t('show_utxos.text_select_utxos_tooltip')}
</Button>
}
/>
</CardContent>
</Card>

View file

@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createUtxoSelectionFormSchema } from './UtxoSelectionDialog.schema'
describe('createUtxoSelectionFormSchema', () => {
const schema = createUtxoSelectionFormSchema(['tx-a:0', 'tx-b:1'])
it('accepts selectable UTXO rows and a filter', async () => {
const values = {
filter: 'confirmed',
rowSelection: { 'tx-a:0': true, 'tx-b:1': false },
}
await expect(schema.validate(values)).resolves.toEqual(values)
})
it('allows clearing the selection', async () => {
await expect(schema.isValid({ filter: '', rowSelection: {} })).resolves.toBe(true)
})
it('rejects selected rows that are not available', async () => {
await expect(schema.isValid({ filter: '', rowSelection: { 'unknown:0': true } })).resolves.toBe(false)
})
})

View file

@ -1,32 +0,0 @@
import type { RowSelectionState } from '@tanstack/react-table'
import * as yup from 'yup'
export type UtxoSelectionFormValues = {
filter: string
rowSelection: RowSelectionState
}
export const UTXO_SELECTION_FORM_DEFAULT_VALUES: UtxoSelectionFormValues = {
filter: '',
rowSelection: {},
}
export const createUtxoSelectionFormSchema = (
selectableUtxoIds: string[],
): yup.ObjectSchema<UtxoSelectionFormValues> => {
const selectableUtxoIdSet = new Set(selectableUtxoIds)
return yup
.object({
filter: yup.string().defined(),
rowSelection: yup
.mixed<RowSelectionState>()
.defined()
.test('selectable-utxos', (value) =>
Object.entries(value).every(
([utxoId, selected]) => typeof selected === 'boolean' && (!selected || selectableUtxoIdSet.has(utxoId)),
),
),
})
.required()
}

View file

@ -37,49 +37,40 @@ export const UtxoSelectionDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-w-6xl flex-col overflow-hidden">
<form
className="contents"
onSubmit={(event) => {
event.preventDefault()
void onSubmit()
}}
noValidate
>
<DialogHeader>
<DialogTitle>{t('show_utxos.title')}</DialogTitle>
<DialogDescription>
{t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')}
</DialogDescription>
</DialogHeader>
<DialogHeader>
<DialogTitle>{t('show_utxos.title')}</DialogTitle>
<DialogDescription>
{t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')}
</DialogDescription>
</DialogHeader>
<Input
className="shrink-0"
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('jar_details.utxo_list.placeholder_search')}
disabled={isSubmitting}
<Input
className="shrink-0"
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('jar_details.utxo_list.placeholder_search')}
disabled={isSubmitting}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<JarUtxosTable
globalFilter={filter}
tableEntries={tableEntries}
pinnedEntries={[]}
initialRowSelection={initialRowSelection}
onRowSelectionChange={onRowSelectionChange}
enableRowSelection={enableRowSelection}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<JarUtxosTable
globalFilter={filter}
tableEntries={tableEntries}
pinnedEntries={[]}
initialRowSelection={initialRowSelection}
onRowSelectionChange={onRowSelectionChange}
enableRowSelection={enableRowSelection}
/>
</div>
</div>
<DialogFooter className="shrink-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t('modal.confirm_button_reject')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : undefined}
{t('modal.confirm_button_accept')}
</Button>
</DialogFooter>
</form>
<DialogFooter className="shrink-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t('modal.confirm_button_reject')}
</Button>
<Button type="button" onClick={() => void onSubmit()} disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : undefined}
{t('modal.confirm_button_accept')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)

View file

@ -26,7 +26,6 @@ import { withQueryDelay } from '@/lib/queryClient'
import { cn, type WalletFileName } from '@/lib/utils'
import { convertExtendedPublicKey } from '@/lib/xpubs'
import type { JarIndex, Milliseconds, MnemonicPhrase, WithRequiredProperty } from '@/types/global'
import { JarBadge } from '../earn/fidelity-bond/FidelityBondDialogParts'
import { Alert, AlertDescription, AlertTitle } from '../ui/alert'
import { Badge } from '../ui/badge'
import { buttonVariants } from '../ui/button-variants'
@ -174,9 +173,9 @@ export const AccountXpubsAccordion = ({ values }: AccountXpubsAccordionProps) =>
<AccordionItem key={index} value={String(account.accountIndex)}>
<AccordionTrigger className="group/xpub-accordion-trigger flex-wrap px-4 no-underline!">
<span className="min-w-0 flex-1 flex-col break-words group-hover/xpub-accordion-trigger:underline">
{accountLabel}
{account.accountName}
</span>
<JarBadge jarIndex={account.accountIndex} name={account.accountName} />
<span className="text-muted-foreground shrink-0 font-mono">{accountLabel}</span>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 p-4 pt-2">
<Label className="text-muted-foreground text-sm">

View file

@ -38,8 +38,6 @@ vi.mock('@/components/ui/select', () => ({
SelectTrigger: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <div data-testid="select-value">{placeholder}</div>,
SelectContent: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectGroup: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectLabel: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
<div data-testid={`select-item-${value}`}>{children}</div>
),

View file

@ -0,0 +1,43 @@
import { GlobeIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import languages from '@/i18n/languages'
export const LanguageSelector = () => {
const { i18n, t } = useTranslation()
const handleLanguageChange = async (languageKey: string) => {
await i18n.changeLanguage(languageKey)
}
const getCurrentLanguageDescription = () => {
const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage)
return currentLanguage?.description || 'English'
}
return (
<div className="flex flex-col items-stretch justify-between gap-2 py-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
<GlobeIcon className="text-muted-foreground h-3 w-3" />
</div>
<div>
<p className="text-sm font-medium">{t('settings.label_select_language')}</p>
</div>
</div>
<Select value={i18n.resolvedLanguage} onValueChange={(value) => void handleLanguageChange(value)}>
<SelectTrigger className="h-7 w-full text-xs sm:w-38" aria-label="Select language">
<SelectValue placeholder={getCurrentLanguageDescription()} />
</SelectTrigger>
<SelectContent>
{languages.map((language) => (
<SelectItem key={language.key} value={language.key}>
{language.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -3,7 +3,6 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { type RescanInfo } from '@/context/JamSessionInfoContext'
import { SEGWIT_ACTIVATION_BLOCK, type WalletFileName } from '@/lib/utils'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import type { BlockHeight } from '@/types/global'
import { RescanChainPage } from './RescanChainPage'
@ -86,20 +85,18 @@ describe('RescanChainPage', () => {
})
it('renders the form', async () => {
await withRuntimeLocale('en-US', async () => {
await renderPage()
await renderPage()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
})
it('renders the form and navigates back', async () => {

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useMemo, type ComponentProps } from 'react'
import { getseedOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { cx } from 'class-variance-authority'
import { AlertTriangleIcon, ClockIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@ -16,7 +17,7 @@ import {
import { Label } from '@/components/ui/label'
import { useApiClient } from '@/hooks/useApiClient'
import { getErrorReason } from '@/lib/errorReason'
import { cn, type WalletFileName } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import type { Milliseconds, WithRequiredProperty } from '@/types/global'
import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid'
import { Spinner } from '../ui/spinner'
@ -32,6 +33,7 @@ type SeedPhraseDialogProps = WithRequiredProperty<
autoCloseTimeout: Milliseconds
}
// TODO: use react-hook-form and yup schema
export const SeedPhraseDialog = ({
open,
onOpenChange,
@ -188,13 +190,13 @@ export const SeedPhraseDialog = ({
<DialogFooter>
<div className="flex w-full items-center justify-between">
<div
className={cn('text-muted-foreground flex items-center gap-1 text-sm', {
className={cx('text-muted-foreground flex items-center gap-1 text-sm', {
'text-destructive! animate-pulse': secondsLeft <= 10,
})}
>
<ClockIcon className="h-4 w-4" />
<span
className={cn('mt-0.5 font-mono', {
className={cx('mt-0.5 font-mono', {
hidden: secondsLeft < 1,
})}
>

View file

@ -1,183 +0,0 @@
import { render, screen } from '@testing-library/react'
import user from '@testing-library/user-event'
import { KeyRoundIcon } from 'lucide-react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem'
const mocks = vi.hoisted(() => ({
navigate: vi.fn(),
open: vi.fn(),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => mocks.navigate,
}))
vi.mock('@/components/ui/switch', () => ({
Switch: ({
checked,
disabled,
onCheckedChange,
}: {
checked?: boolean
disabled?: boolean
onCheckedChange?: (checked: boolean) => void
}) => (
<button type="button" disabled={disabled} onClick={() => onCheckedChange?.(!checked)}>
switch:{String(checked)}
</button>
),
}))
describe('SettingsItem', () => {
beforeEach(() => {
mocks.navigate.mockReset()
mocks.open.mockReset()
})
it('renders title and icon', () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" />)
expect(screen.getByText('settings.show_seed')).toBeInTheDocument()
})
it('exposes an actionable row as a button', () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
expect(screen.getByRole('button', { name: 'settings.show_seed' })).toBeInTheDocument()
})
it('does not render a button without an action', () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is reachable by keyboard', async () => {
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
await user.tab()
expect(screen.getByRole('button', { name: 'settings.show_seed' })).toHaveFocus()
})
it('triggers the action on Enter', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.tab()
await user.keyboard('{Enter}')
expect(action).toHaveBeenCalledOnce()
})
it('triggers the action on Space', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.tab()
await user.keyboard(' ')
expect(action).toHaveBeenCalledOnce()
})
it('triggers the action on click', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.click(screen.getByRole('button', { name: 'settings.show_seed' }))
expect(action).toHaveBeenCalledOnce()
})
it('does not trigger the action while disabled', async () => {
const action = vi.fn()
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} disabled={true} />)
const item = screen.getByRole('button', { name: 'settings.show_seed' })
expect(item).toBeDisabled()
await user.click(item)
await user.tab()
await user.keyboard('{Enter}')
expect(action).not.toHaveBeenCalled()
})
it('stays a wrapper when children provide their own control', () => {
render(
<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} hasInteractiveChild={true}>
<button type="button">child-control</button>
</SettingsItem>,
)
// no button is nested inside another button
expect(screen.queryByRole('button', { name: 'settings.show_seed' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'child-control' })).toBeInTheDocument()
})
})
describe('SettingsLink', () => {
beforeEach(() => {
mocks.navigate.mockReset()
})
it('navigates internally on Enter', async () => {
render(<SettingsLink icon={KeyRoundIcon} title="settings.rescan_chain" to="/rescan" />)
await user.tab()
await user.keyboard('{Enter}')
expect(mocks.navigate).toHaveBeenCalledWith('/rescan')
})
it('opens external links in a new tab on Enter', async () => {
vi.stubGlobal('open', mocks.open)
render(<SettingsLink icon={KeyRoundIcon} title="settings.documentation" to="https://example.org" external={true} />)
await user.tab()
await user.keyboard('{Enter}')
expect(mocks.open).toHaveBeenCalledWith('https://example.org', '_blank', 'noreferrer,noopener')
expect(mocks.navigate).not.toHaveBeenCalled()
})
})
describe('SettingSwitch', () => {
it('is keyboard operable via the row when no toggle is displayed', async () => {
const onCheckedChange = vi.fn()
render(
<SettingsSwitch
icon={KeyRoundIcon}
title="settings.hide_balance"
checked={false}
onCheckedChange={onCheckedChange}
displayToggle={false}
/>,
)
await user.tab()
await user.keyboard('{Enter}')
expect(onCheckedChange).toHaveBeenCalledWith(true)
})
it('is keyboard operable via the toggle when one is displayed', async () => {
const onCheckedChange = vi.fn()
render(
<SettingsSwitch
icon={KeyRoundIcon}
title="settings.use_address_chunking_enabled"
checked={false}
onCheckedChange={onCheckedChange}
displayToggle={true}
/>,
)
await user.tab()
expect(screen.getByRole('button', { name: 'switch:false' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(onCheckedChange).toHaveBeenCalledWith(true)
})
})

View file

@ -1,9 +1,9 @@
import type { PropsWithChildren, ReactNode } from 'react'
import { cx } from 'class-variance-authority'
import { ExternalLinkIcon } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
type SettingsItemProps = PropsWithChildren<{
icon?: LucideIcon
@ -11,37 +11,24 @@ type SettingsItemProps = PropsWithChildren<{
title: string
disabled?: boolean
action?: () => void | Promise<void>
/**
* Set when {@link children} already renders its own focusable control (e.g. a `Switch`).
* The row then stays a plain wrapper instead of becoming a button, so a button is never
* nested inside a button - keyboard users operate the child control directly.
*/
hasInteractiveChild?: boolean
}>
export const SettingsItem = ({
export const SettingItem = ({
icon: Icon,
renderIcon,
title,
action,
disabled = false,
hasInteractiveChild = false,
children,
}: SettingsItemProps) => {
const rowClassName = cn('flex min-w-0 items-center justify-between gap-2 py-2', {
'hover:bg-muted/50 cursor-pointer': !disabled && action,
'rounded-md px-2': !disabled,
'cursor-not-allowed opacity-60': disabled,
})
// The row bleeds into the card padding so the hover background spans the full width.
// A block `div` with `width: auto` gets this from the negative margin alone, but a
// `button` does not stretch to its parent, and once `w-full` pins the width the
// negative margin no longer widens it. Keeping the bleed on a wrapper makes `w-full`
// resolve against the widened box, so both variants end up the same size.
const bleedClassName = !disabled ? '-mx-2' : undefined
const content = (
<>
<div
className={cx('flex min-w-0 items-center justify-between gap-2 py-2', {
'hover:bg-muted/50 -mx-2 cursor-pointer rounded-md px-2': !disabled,
'cursor-not-allowed opacity-60': disabled,
})}
onClick={!disabled && action ? () => void action() : undefined}
>
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
{Icon && <Icon className="text-muted-foreground h-4 w-4 align-middle" />}
@ -52,34 +39,13 @@ export const SettingsItem = ({
</div>
</div>
{children !== undefined && <div className="shrink-0">{children}</div>}
</>
)
// A row that is itself the control must be a real button: the browser then provides
// focus, Enter/Space activation and the correct role for free.
if (action && !hasInteractiveChild) {
return (
<div className={bleedClassName}>
<button
type="button"
className={cn(rowClassName, 'w-full appearance-none border-0 bg-transparent text-left')}
onClick={() => void action()}
disabled={disabled}
>
{content}
</button>
</div>
)
}
return (
<div className={cn(bleedClassName, rowClassName)} onClick={!disabled && action ? () => void action() : undefined}>
{content}
</div>
)
return content
}
type SettingsLinkProps = Omit<SettingsItemProps, 'action' | 'children' | 'hasInteractiveChild'> & {
type SettingsLinkProps = Omit<SettingsItemProps, 'action' | 'children'> & {
to: string
external?: boolean
}
@ -88,7 +54,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
const navigate = useNavigate()
return (
<SettingsItem
<SettingItem
{...props}
action={async () => {
if (external) {
@ -99,22 +65,19 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
}}
>
{external && <ExternalLinkIcon className="text-muted-foreground size-3.5" />}
</SettingsItem>
</SettingItem>
)
}
type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action' | 'hasInteractiveChild'> & {
type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action'> & {
checked?: boolean
onCheckedChange?: (checked: boolean) => void
displayToggle?: boolean
}
export const SettingsSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
export const SettingSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
return (
// With a visible toggle the `Switch` is the focusable control and the row stays a
// wrapper. Without one the row itself has to be the button, or the setting cannot be
// reached by keyboard at all.
<SettingsItem {...props} action={() => onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}>
<SettingItem {...props} action={() => onCheckedChange?.(!checked)}>
{displayToggle && <Switch checked={checked} onCheckedChange={onCheckedChange} disabled={props.disabled} />}
</SettingsItem>
</SettingItem>
)
}

View file

@ -59,9 +59,6 @@ vi.mock('next-themes', () => ({
vi.mock('react-i18next', () => ({
useTranslation: () => ({
i18n: {
resolvedLanguage: 'en',
},
t: (key: string) => key,
}),
}))
@ -126,7 +123,7 @@ vi.mock('@/components/settings/AccountXpubsDialog', () => ({
) : null,
}))
vi.mock('@/components/ui/jam/LanguageSelector', () => ({
vi.mock('@/components/settings/LanguageSelector', () => ({
LanguageSelector: () => <div>language-selector</div>,
}))

View file

@ -16,9 +16,6 @@ import {
UnfoldHorizontalIcon,
KeyRoundIcon,
HandCoinsIcon,
SparklesIcon,
HistoryIcon,
LanguagesIcon,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
@ -26,10 +23,8 @@ import { useNavigate, type NavigateFunction } from 'react-router-dom'
import { useStore } from 'zustand'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { LanguageSelector } from '@/components/ui/jam/LanguageSelector'
import PageTitle from '@/components/ui/jam/PageTitle'
import { Separator } from '@/components/ui/separator'
import { Spinner } from '@/components/ui/spinner'
import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { JAM_DOCS_URL, JAM_MATRIX_URL, JAM_REPO_URL, JAM_SEED_MODAL_TIMEOUT, JAM_TELEGRAM_URL } from '@/constants/jam'
import { routes } from '@/constants/routes'
@ -39,9 +34,11 @@ import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { cn, type WalletFileName } from '@/lib/utils'
import { authStore } from '@/store/authStore'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { Spinner } from '../ui/spinner'
import { AccountXpubsDialog } from './AccountXpubsDialog'
import { LanguageSelector } from './LanguageSelector'
import { SeedPhraseDialog } from './SeedPhraseDialog'
import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem'
import { SettingItem, SettingsLink, SettingSwitch } from './SettingsItem'
import { FeeConfigDialog } from './fees/FeeConfigDialog'
interface SettingPageProps {
@ -82,7 +79,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_display')}</CardTitle>
</CardHeader>
<CardContent>
<SettingsSwitch
<SettingSwitch
icon={isPrivate ? EyeIcon : EyeOffIcon}
title={t(isPrivate ? 'settings.show_balance' : 'settings.hide_balance')}
checked={isPrivate}
@ -90,7 +87,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<SettingsSwitch
<SettingSwitch
renderIcon={({ className }) => <CurrencySymbol currency={currency} className={className} />}
title={t(currency === 'btc' ? 'settings.use_btc' : 'settings.use_sats')}
checked={currency === 'btc'}
@ -98,7 +95,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<SettingsSwitch
<SettingSwitch
icon={addressChunkingEnabled === true ? UnfoldHorizontalIcon : FoldHorizontalIcon}
title={t(
addressChunkingEnabled === true
@ -110,7 +107,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={true}
/>
<Separator className="opacity-50" />
<SettingsSwitch
<SettingSwitch
icon={resolvedTheme === 'dark' ? SunIcon : MoonIcon}
title={resolvedTheme === 'dark' ? t('settings.use_light_theme') : t('settings.use_dark_theme')}
checked={resolvedTheme === 'dark'}
@ -118,9 +115,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<SettingsItem icon={LanguagesIcon} title={t('settings.label_select_language')}>
<LanguageSelector />
</SettingsItem>
<LanguageSelector />
</CardContent>
</Card>
@ -130,7 +125,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_market')}</CardTitle>
</CardHeader>
<CardContent>
<SettingsItem
<SettingItem
icon={HandCoinsIcon}
title={t('settings.show_fee_config')}
action={() => setShowFeeConfigDialog(true)}
@ -144,21 +139,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_wallet')}</CardTitle>
</CardHeader>
<CardContent className="space-y-0">
<SettingsItem
<SettingItem
icon={KeyRoundIcon}
title={t('settings.show_seed')}
action={() => setShowSeedDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingsItem
<SettingItem
icon={BookKeyIcon}
title={t('settings.show_xpubs')}
action={() => setShowXpubsDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingsItem
<SettingItem
renderIcon={({ className }) =>
lockWalletMutation.isPending ? (
<Spinner className={className} />
@ -219,39 +214,10 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
to={JAM_REPO_URL}
external={true}
/>
<Separator className="opacity-50" />
<SettingsSwitch
icon={SparklesIcon}
title={/* TODO: i18n */ 'Enable Feature Preview'}
disabled={!isDevMode()}
checked={!!jamSettings.state.previewFeatures}
onCheckedChange={(checked) => {
jamSettings.update({ previewFeatures: checked ? {} : undefined })
}}
/>
{!!jamSettings.state.previewFeatures && (
<>
<Separator className="opacity-50" />
<SettingsSwitch
icon={HistoryIcon}
title={/* TODO: i18n */ 'Transaction History (Experimental)'}
disabled={!isDevMode()}
checked={!!jamSettings.state.previewFeatures?.['tx-history'] === true}
onCheckedChange={(checked) => {
jamSettings.update({
previewFeatures: {
...jamSettings.state.previewFeatures,
'tx-history': checked,
},
})
}}
/>
</>
)}
{isDevMode() && (
<>
<Separator className="opacity-50" />
<SettingsSwitch
<SettingSwitch
icon={TerminalIcon}
title="Enable developer mode"
disabled={!isDevMode()}

View file

@ -6,18 +6,13 @@ import { SweepPreconditionAlert } from './SweepPreconditionAlert'
import type { SweepPreconditionSummary } from './preconditions'
vi.mock('react-i18next', () => ({
Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) =>
values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey,
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
t: (key: string) => key,
}),
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}sats</span>,
}))
const retryLockedUtxo: Utxo = {
const retryLockedUtxo = {
address: 'bc1qretry',
confirmations: 12,
frozen: false,
@ -25,22 +20,18 @@ const retryLockedUtxo: Utxo = {
locktime: undefined,
mixdepth: 0,
path: '',
tries: 3,
external: false,
tries_remaining: 0,
utxo: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:0',
value: 123_456,
}
} as unknown as Utxo
const makeSummary = (overrides: Partial<SweepPreconditionSummary> = {}): SweepPreconditionSummary => ({
isFulfilled: false,
numberOfMissingConfirmations: 0,
numberOfMissingUtxos: 0,
numberOfNonFrozenFidelityBondOutputs: 0,
options: {
minConfirmations: 5,
minNumberOfUtxos: 1,
maxNumberOfNonFrozenFidelityBondOutputs: 0,
},
retryLockedUtxos: [],
...overrides,
@ -66,8 +57,8 @@ describe('precondition alerts', () => {
</>,
)
expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos:{"minConfirmations":5}')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_utxos:{"minConfirmations":5}')).toBeInTheDocument()
expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_utxos')).toBeInTheDocument()
})
it('shows missing confirmation warnings for send and sweep flows', () => {
@ -80,30 +71,8 @@ describe('precondition alerts', () => {
</>,
)
expect(
screen.getByText(
'send.coinjoin_precondition.hint_missing_confirmations:{"minConfirmations":5,"amountOfMissingConfirmations":3}',
),
).toBeInTheDocument()
expect(
screen.getByText(
'scheduler.precondition.hint_missing_confirmations:{"minConfirmations":5,"amountOfMissingConfirmations":3}',
),
).toBeInTheDocument()
})
it('shows non-frozen fidelity bond warnings for send and sweep flows', () => {
const summary = makeSummary({ numberOfNonFrozenFidelityBondOutputs: 3 })
render(
<>
<SendCoinjoinPreconditionAlert summary={summary} />
<SweepPreconditionAlert summary={summary} />
</>,
)
expect(screen.getByText('send.coinjoin_precondition.hint_non_frozen_fidelity_bond:{"count":3}')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_non_frozen_fidelity_bond:{"count":3}')).toBeInTheDocument()
expect(screen.getByText('send.coinjoin_precondition.hint_missing_confirmations')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_confirmations')).toBeInTheDocument()
})
it('lists retry-locked utxos when retries are the remaining blocker', () => {
@ -119,6 +88,7 @@ describe('precondition alerts', () => {
expect(screen.getByText('send.coinjoin_precondition.hint_missing_retries')).toBeInTheDocument()
expect(screen.getByText('scheduler.precondition.hint_missing_retries')).toBeInTheDocument()
expect(screen.getAllByText('global.utxos')).toHaveLength(2)
expect(screen.getAllByText('123456sats')).toHaveLength(2)
expect(screen.getAllByText('jar 0')).toHaveLength(2)
expect(screen.getAllByText('123,456 sats')).toHaveLength(2)
})
})

View file

@ -1,35 +1,19 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { SweepDestinationInputs } from './SweepDestinationInputs'
import type { SweepFormValues } from './SweepFormSchema'
type SweepForm = UseFormReturn<SweepFormValues, unknown, SweepFormValues>
type SweepFields = Array<FieldArrayWithId<SweepFormValues, 'destinations', 'id'>>
const h = vi.hoisted(() => {
const DEFAULT_NEW_DUMMY_ADDRESS_0 = 'bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk'
return {
DEFAULT_NEW_DUMMY_ADDRESS_0,
toastSuccess: vi.fn<(message: string) => void>(),
}
})
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => (options ? `${key} ${JSON.stringify(options)}` : key),
}),
}))
vi.mock('sonner', () => ({
toast: {
success: (message: string) => {
h.toastSuccess(message)
},
},
}))
describe('SweepDestinationInputs', () => {
const defaultFormMock = {
formState: {
@ -46,11 +30,6 @@ describe('SweepDestinationInputs', () => {
{ id: '2', address: '' },
] as unknown as SweepFields
beforeEach(() => {
vi.clearAllMocks()
h.toastSuccess = vi.fn<(message: string) => void>()
})
it('renders input fields correctly', async () => {
render(
<SweepDestinationInputs
@ -139,7 +118,6 @@ describe('SweepDestinationInputs', () => {
)
const inputs = screen.getAllByRole('textbox')
expect(inputs).toHaveLength(fields.length)
await act(async () => {
await userEvent.click(inputs[0])
await userEvent.paste('anything')
@ -150,33 +128,19 @@ describe('SweepDestinationInputs', () => {
await userEvent.click(inputs[0])
await userEvent.paste('bitcoin:')
})
expect(defaultFormMock.setValue).not.toHaveBeenCalled()
await act(async () => {
await userEvent.click(inputs[0])
await userEvent.paste(h.DEFAULT_NEW_DUMMY_ADDRESS_0)
})
expect(defaultFormMock.setValue).toHaveBeenCalledWith(`destinations.0.address`, h.DEFAULT_NEW_DUMMY_ADDRESS_0, {
shouldValidate: true,
})
expect(h.toastSuccess).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 },
)
expect(h.toastSuccess).toHaveBeenCalledWith('send.qr_scan_bip21_applied')
})
it('adds additional destination inputs', () => {

View file

@ -55,9 +55,7 @@ export const SweepDestinationInputs = ({
event.preventDefault()
applyBip21Result(parsed, index)
if (parsed.fromUri) {
toast.success(t('send.qr_scan_bip21_applied'))
}
toast.success(t('send.qr_scan_bip21_applied'))
},
[applyBip21Result, t],
)

View file

@ -1,6 +1,5 @@
import { useMemo, useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import type { Network } from 'bitcoin-address-validation'
import { AlertTriangleIcon } from 'lucide-react'
import { useFieldArray, useForm, useWatch, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
@ -54,7 +53,6 @@ interface SweepFormProps {
initialValues?: Partial<SweepFormValues>
jars: Jar[]
addressSummary: AddressSummary
network: Network
disabled?: boolean
debug?: boolean
}
@ -64,7 +62,6 @@ export const SweepForm = ({
onSubmit,
jars,
addressSummary,
network,
initialValues,
disabled,
debug,
@ -85,11 +82,10 @@ export const SweepForm = ({
minNumberOfDestinations,
maxNumberOfDestinations,
addressSummary,
network,
},
t,
),
[minNumberOfDestinations, maxNumberOfDestinations, addressSummary, network, t],
[minNumberOfDestinations, maxNumberOfDestinations, addressSummary, t],
)
const { formState, reset, register, control, setValue, handleSubmit, trigger } = useForm<
SweepFormValues,

View file

@ -1,4 +1,3 @@
import { Network } from 'bitcoin-address-validation'
import type { TFunction } from 'i18next'
import { describe, expect, it } from 'vitest'
import type { AddressSummary } from '@/context/JamWalletInfoContext'
@ -13,19 +12,13 @@ import {
const t = ((key: string) => key) as unknown as TFunction<'translation', undefined>
const validRegtestAddress = 'bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx'
const validMainnetAddress = '1BitcoinEaterAddressDontSend8MUo1T'
const validate = async (
values: SweepFormValues,
addressSummary = {} as AddressSummary,
network: Network = Network.regtest,
) => {
const validate = async (values: SweepFormValues, addressSummary = {} as AddressSummary) => {
return await sweepFormSchema(
{
minNumberOfDestinations: 1,
maxNumberOfDestinations: 5,
addressSummary,
network,
},
t,
).validate(values, { abortEarly: false })
@ -56,22 +49,6 @@ describe('sweepFormSchema', () => {
})
})
it('rejects an address from the wrong network', async () => {
await expect(
validate({
...buildSweepFormValuesDefaultValues(),
destinations: [{ address: validMainnetAddress }],
}),
).rejects.toMatchObject({
inner: [
expect.objectContaining({
path: 'destinations[0].address',
message: 'scheduler.feedback_destination_network_mismatch',
}),
],
})
})
it('rejects duplicate destination addresses', async () => {
await expect(
validate({

View file

@ -1,4 +1,3 @@
import type { Network } from 'bitcoin-address-validation'
import type { TFunction } from 'i18next'
import * as yup from 'yup'
import {
@ -16,7 +15,7 @@ import {
} from '@/constants/jam'
import { JM_NG_DEFAULT_TUMBLER_PARAMS, type TumblerParameters } from '@/constants/jm'
import type { AddressSummary } from '@/context/JamWalletInfoContext'
import { isAddressOnNetwork, isValidAddress } from '@/lib/formValidation'
import { isValidAddress } from '@/lib/formValidation'
import { factorToPercentage, isValidNumber, percentageToFactor, pseudoRandomInteger } from '@/lib/utils'
import type { Seconds } from '@/types/global'
import { buildDestinationErrors, normalizeDestinationAddresses } from './destinationValidation'
@ -116,17 +115,14 @@ export const sweepFormSchema = (
minNumberOfDestinations,
maxNumberOfDestinations,
addressSummary,
network,
}: {
minNumberOfDestinations: number
maxNumberOfDestinations: number
addressSummary: AddressSummary
network: Network
},
t: TFunction<'translation', undefined>,
): yup.ObjectSchema<SweepFormValues> => {
const invalidDestinationAddressMessage = t('scheduler.feedback_invalid_destination_address')
const networkMismatchDestinationAddressMessage = t('scheduler.feedback_destination_network_mismatch')
const invalidNumberOfDestinationsMessage = t('send.feedback_invalid_number_of_destination_addresses', {
// TODO: i18n
defaultValue: 'Please provide between {{ min }} and {{ max }} destination addresses.',
@ -141,20 +137,20 @@ export const sweepFormSchema = (
.of(
yup
.object({
// TODO: use formValidation#destinationAddressField ?
address: yup
.string()
.transform((_, originalValue: unknown) =>
typeof originalValue === 'string' ? normalizeDestinationAddresses([originalValue])[0] : '',
)
.defined()
.test('valid-sweep-destination', invalidDestinationAddressMessage, (value) => isValidAddress(value))
// Only run once the address itself is valid, so an invalid address surfaces a
// single, correct error instead of also reporting a network mismatch.
.test(
'sweep-destination-network-mismatch',
networkMismatchDestinationAddressMessage,
(value) => !isValidAddress(value) || isAddressOnNetwork(value, network),
),
.test('valid-sweep-destination', invalidDestinationAddressMessage, function (value) {
if (!isValidAddress(value)) {
return false
}
return true
}),
})
.required(),
)

View file

@ -107,19 +107,6 @@ const mocks = vi.hoisted(() => ({
stopState: { isPending: false, isSuccess: false },
toastError: vi.fn(),
walletInfo: undefined as WalletInfo | undefined,
hasOrders: true,
orderbookIsLoading: false,
orderbookError: false,
}))
vi.mock('@/hooks/useQueryOrderbook', () => ({
useQueryOrderbook: () => ({
hasOrders: mocks.hasOrders,
queryResult: {
isLoading: mocks.orderbookIsLoading,
isError: mocks.orderbookError,
},
}),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
@ -277,7 +264,6 @@ vi.mock('@/components/ui/jam/PageLoading', () => ({
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => mocks.walletInfo,
useDetectNetwork: () => ({ network: 'regtest' }),
}))
vi.mock('@/hooks/useApiClient', () => ({
@ -316,7 +302,6 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
calculatedAvailableBalanceInSats: 100_000,
calculatedTotalBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#e2b86a',
@ -329,7 +314,6 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
calculatedAvailableBalanceInSats: 0,
calculatedTotalBalanceInSats: 0,
calculatedConfirmedAvailableBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
color: '#ccc',
@ -344,7 +328,6 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
detectedNetwork: null,
error: null,
fidelityBondSummary: { fbOutputs: [] },
hasEligibleFidelityBondUtxo: false,
isFetching: false,
isLoading: false,
jars: [jar, emptyJar(1), emptyJar(2)],
@ -357,7 +340,6 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
calculatedAvailableBalanceInSats: 100_000,
calculatedTotalBalanceInSats: 100_000,
calculatedConfirmedAvailableBalanceInSats: 100_000,
calculatedAvailableFrozenBalanceInSats: 0,
calculatedFrozenOrLockedBalanceInSats: 0,
},
walletName: 'wallet.jmdat',
@ -417,9 +399,6 @@ describe('SweepPage', async () => {
mocks.stopState = { isPending: false, isSuccess: false }
mocks.toastError.mockReset()
mocks.walletInfo = makeWalletInfo()
mocks.hasOrders = true
mocks.orderbookIsLoading = false
mocks.orderbookError = false
setSession()
vi.clearAllMocks()
@ -738,27 +717,4 @@ describe('SweepPage', async () => {
await flushActUpdates()
})
})
describe('Orderbook Precheck', () => {
it('shows empty orderbook warning when orderbook has no offers', () => {
mocks.hasOrders = false
render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('orderbook.alert_precheck_empty_title')).toBeInTheDocument()
})
it('does not show empty orderbook warning when orderbook is loading or has error', () => {
mocks.hasOrders = false
mocks.orderbookIsLoading = true
const { rerender } = render(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
mocks.orderbookIsLoading = false
mocks.orderbookError = true
rerender(<SweepPage walletFileName="wallet.jmdat" />)
expect(screen.queryByText('orderbook.alert_precheck_empty_title')).not.toBeInTheDocument()
})
})
})

View file

@ -23,21 +23,20 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Balance } from '@/components/ui/jam/Balance'
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { OrderbookEmptyAlert } from '@/components/ui/jam/OrderbookEmptyAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import * as JAM from '@/constants/jam'
import { isDevMode } from '@/constants/debugFeatures'
import type { TumblerParameters } from '@/constants/jm'
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
import { useDetectNetwork, useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import { getErrorReason } from '@/lib/errorReason'
import { cn, scrollToTop, 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'
@ -47,6 +46,11 @@ interface SweepPageProps {
walletFileName: WalletFileName
}
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
const RUNNING_SCHEDULE_POLLING_INTERVAL: Milliseconds = isDevMode() ? 5_000 : 10_000
const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial<TumblerParameters> = {
time_lambda_seconds: 10,
stage1_wait_multiplier: 1.5,
@ -58,31 +62,14 @@ const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial<TumblerParameters> = {
export const SweepPage = ({ walletFileName }: SweepPageProps) => {
const { t } = useTranslation()
const client = useApiClient()
const {
rescanInfo,
makerInfo: { running: makerRunning },
takerInfo: {
running: takerRunning,
scheduler: { running: schedulerRunning },
},
} = useJamSessionInfoContext()
const { rescanInfo, takerInfo, makerInfo } = useJamSessionInfoContext()
const jmSession = useStore(jmSessionStore, (state) => state.state)
const walletInfo = useJamWalletInfoContext()
const { network } = useDetectNetwork()
const { enabled: isDeveloperMode } = useDeveloperMode()
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
const [showScheduleConfirmDialog, setShowScheduleConfirmDialog] = useState<TumblerPlanRequest>()
const [alertMessage, setAlertMessage] = useState<string>()
const {
hasOrders,
queryResult: {
isLoading: orderbookCheckIsLoading,
isError: orderbookCheckIsError,
isFetching: orderbookCheckIsFetching,
refetch: orderbookRefetch,
},
} = useQueryOrderbook()
const feeConfigValidation = useFeeConfigValidation({ walletFileName })
@ -97,8 +84,9 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
path: { walletname: walletFileName },
}),
refetchInterval: (query) => {
const schedulerRunning = takerInfo.running && takerInfo.scheduler.running
const currentScheduleStillActive = query.state.data && !isPlanTerminated(query.state.data)
return schedulerRunning || currentScheduleStillActive ? JAM.RUNNING_SCHEDULE_POLLING_INTERVAL : false
return schedulerRunning || currentScheduleStillActive ? RUNNING_SCHEDULE_POLLING_INTERVAL : false
},
refetchIntervalInBackground: true,
retry: false,
@ -206,18 +194,21 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
return toSchedule(getScheduleQuery.data, walletInfo.jars)
}, [getScheduleQuery.error, getScheduleQuery.data, walletInfo.jars])
const schedulerRunning = takerInfo.scheduler.running
const isWaitingSchedulerStart =
startScheduleMutationIsPending || (startScheduleMutationIsSuccess && !schedulerRunning)
const waitingForTumblerStatus = takerRunning && getScheduleQuery.isPending
const waitingForTumblerStatus = takerInfo.running && getScheduleQuery.isPending
const singleCoinJoinRunning =
takerRunning && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
takerInfo.running && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
const makerRunning = makerInfo.running === true
const collaborativeOperationRunning = makerRunning || takerInfo.running
const isWaitingSchedulerStop = stopScheduleMutationIsPending || (stopScheduleMutationIsSuccess && schedulerRunning)
useRefreshSession({
enabled: isWaitingSchedulerStart || isWaitingSchedulerStop,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
})
useEffect(() => {
@ -234,10 +225,8 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
const isOperationDisabled =
feeConfigValidation.maxFeesConfigMissing ||
makerRunning ||
takerRunning ||
collaborativeOperationRunning ||
rescanInfo.rescanning ||
makerRunning ||
!preconditionSummary.isFulfilled
const isStartDisabled =
@ -295,10 +284,6 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
{!orderbookCheckIsLoading && !orderbookCheckIsError && !hasOrders && (
<OrderbookEmptyAlert isChecking={orderbookCheckIsFetching} onCheckClick={orderbookRefetch} />
)}
{alertMessage && (
<Alert variant="destructive">
<AlertTitle>{t('global.error')}</AlertTitle>
@ -309,14 +294,14 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
{singleCoinJoinRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
</Alert>
)}
{makerRunning && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>{t('send.text_maker_running')}</AlertTitle>
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
</Alert>
)}
@ -450,11 +435,7 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
{!currentSchedule && !schedulerRunning && (
<>
<Card
className={cn('transition-all duration-500', {
'blur-[2px]': takerRunning || makerRunning,
})}
>
<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">
<div className="min-w-0">
@ -471,7 +452,6 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
<SweepForm
jars={walletInfo.jars}
addressSummary={walletInfo.addressSummary}
network={network}
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
debug={isDeveloperMode}
onSubmit={async (values) => {

View file

@ -1,10 +1,84 @@
import { AlertTriangleIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
import { formatSats, shortenStringMiddle } from '@/lib/utils'
interface SweepPreconditionAlertProps {
summary: SweepPreconditionSummary
}
export const SweepPreconditionAlert = ({ summary }: SweepPreconditionAlertProps) => {
return <PreconditionAlert summary={summary} i18nPrefix="scheduler.precondition" />
const RetryLockedUtxoList = ({ summary }: SweepPreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.retryLockedUtxos.length === 0) {
return null
}
return (
<>
<Trans
i18nKey="scheduler.precondition.hint_missing_retries"
components={[
<></>,
<a key="retries-doc-link" href={JAM_JM_RETRIES_DOCS_URL} target="_blank" rel="noopener noreferrer" />,
]}
/>
<div className="mt-3 rounded-md border p-2">
<div className="mb-2 text-xs font-semibold">{t('global.utxos')}</div>
<ul className="space-y-1">
{summary.retryLockedUtxos.map((utxo) => (
<li key={utxo.utxo} className="bg-muted/50 rounded-sm px-2 py-1 font-mono text-xs">
<span className="mr-2 font-semibold">jar {utxo.mixdepth}</span>
<span className="mr-2">{formatSats(utxo.value)} sats</span>
<span>{shortenStringMiddle(utxo.utxo, 26)}</span>
</li>
))}
</ul>
</div>
</>
)
}
export const SweepPreconditionAlert = ({ summary }: SweepPreconditionAlertProps) => {
const { t } = useTranslation()
if (summary.isFulfilled) {
return null
}
if (summary.numberOfMissingUtxos > 0) {
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertDescription>
{t('scheduler.precondition.hint_missing_utxos', { minConfirmations: summary.options.minConfirmations })}
</AlertDescription>
</Alert>
)
}
if (summary.numberOfMissingConfirmations > 0) {
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertDescription>
{t('scheduler.precondition.hint_missing_confirmations', {
minConfirmations: summary.options.minConfirmations,
amountOfMissingConfirmations: summary.numberOfMissingConfirmations,
})}
</AlertDescription>
</Alert>
)
}
return (
<Alert variant="warning">
<AlertTriangleIcon />
<AlertDescription>
<RetryLockedUtxoList summary={summary} />
</AlertDescription>
</Alert>
)
}

View file

@ -13,7 +13,7 @@ describe('buildDestinationErrors', () => {
})
it('reports duplicate addresses', () => {
const validAddress = '1BitcoinEaterAddressDontSend8MUo1T'
const validAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
const errors = buildDestinationErrors([validAddress, validAddress, validAddress], {}, t)
expect(errors).toEqual([
@ -24,8 +24,8 @@ describe('buildDestinationErrors', () => {
})
it('reports reused addresses from wallet history', () => {
const usedAddress = '1BitcoinEaterAddressDontSend8MUo1T'
const freshAddress = '1BitcoinEaterAddressDontSendDHyNcX'
const usedAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
const freshAddress = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'
const addressSummary = {
[usedAddress]: {
used: true,

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { buildSweepPreconditionSummary } from './preconditions'
const makeUtxo = (overrides: Partial<Utxo>): Utxo => {
const utxo = (overrides: Partial<Utxo>): Utxo => {
return {
utxo: 'txid:0',
address: 'bc1qexampleaddress000000000000000000000000000',
@ -22,72 +22,45 @@ const makeUtxo = (overrides: Partial<Utxo>): Utxo => {
describe('buildSweepPreconditionSummary', () => {
it('returns fulfilled when at least one eligible utxo is available', () => {
const summary = buildSweepPreconditionSummary([makeUtxo({})])
const summary = buildSweepPreconditionSummary([utxo({})])
expect(summary.isFulfilled).toBe(true)
expect(summary.numberOfMissingUtxos).toBe(0)
expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0)
expect(summary.numberOfMissingConfirmations).toBe(0)
expect(summary.retryLockedUtxos).toHaveLength(0)
})
it('reports missing confirmations when all eligible utxos are too new', () => {
const summary = buildSweepPreconditionSummary([makeUtxo({ confirmations: 2 })], { minConfirmations: 5 })
const summary = buildSweepPreconditionSummary([utxo({ confirmations: 2 })], { minConfirmations: 5 })
expect(summary.isFulfilled).toBe(false)
expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0)
expect(summary.numberOfMissingUtxos).toBe(0)
expect(summary.numberOfMissingConfirmations).toBe(3)
expect(summary.retryLockedUtxos).toHaveLength(0)
})
it('reports retry-locked utxos when one jar has no retries left', () => {
const summary = buildSweepPreconditionSummary([
makeUtxo({ utxo: 'a:0', mixdepth: 0, tries_remaining: 0 }),
makeUtxo({ utxo: 'b:0', mixdepth: 0, tries_remaining: 0 }),
makeUtxo({ utxo: 'c:0', mixdepth: 1, tries_remaining: 1 }),
utxo({ utxo: 'a:0', mixdepth: 0, tries_remaining: 0 }),
utxo({ utxo: 'b:0', mixdepth: 0, tries_remaining: 0 }),
utxo({ utxo: 'c:0', mixdepth: 1, tries_remaining: 1 }),
])
expect(summary.isFulfilled).toBe(false)
expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0)
expect(summary.numberOfMissingUtxos).toBe(0)
expect(summary.numberOfMissingConfirmations).toBe(0)
expect(summary.retryLockedUtxos.map((it) => it.utxo)).toEqual(['a:0', 'b:0'])
})
it('ignores frozen utxos when counting eligibility', () => {
it('ignores frozen or timelocked utxos when counting eligibility', () => {
const summary = buildSweepPreconditionSummary([
makeUtxo({ utxo: 'a:0', frozen: true }),
makeUtxo({
utxo({ utxo: 'a:0', frozen: true }),
utxo({
utxo: 'b:0',
locktime: '2999-01-01 00:00:00',
path: "m/84'/0'/0'/0/0:32503680000",
frozen: true,
}),
])
expect(summary.isFulfilled).toBe(false)
expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(0)
expect(summary.numberOfMissingUtxos).toBe(1)
expect(summary.numberOfMissingConfirmations).toBe(0)
expect(summary.retryLockedUtxos).toHaveLength(0)
})
it('report non-frozen fidelity bonds', () => {
const summary = buildSweepPreconditionSummary([
makeUtxo({ utxo: 'a:0', frozen: true }),
makeUtxo({
utxo: 'b:0',
locktime: '2999-01-01 00:00:00',
path: "m/84'/0'/0'/0/0:32503680000",
frozen: false,
}),
])
expect(summary.isFulfilled).toBe(false)
expect(summary.numberOfNonFrozenFidelityBondOutputs).toBe(1)
expect(summary.numberOfMissingUtxos).toBe(0)
expect(summary.numberOfMissingConfirmations).toBe(0)
expect(summary.retryLockedUtxos).toHaveLength(0)
})
})

View file

@ -5,7 +5,6 @@ import * as fb from '@/lib/fidelityBondUtils'
export interface SweepPreconditionOptions {
minNumberOfUtxos: number
minConfirmations: number
maxNumberOfNonFrozenFidelityBondOutputs: number
}
export interface SweepPreconditionSummary {
@ -13,15 +12,17 @@ export interface SweepPreconditionSummary {
options: SweepPreconditionOptions
numberOfMissingUtxos: number
numberOfMissingConfirmations: number
numberOfNonFrozenFidelityBondOutputs: number
retryLockedUtxos: Utxo[]
}
export const SWEEP_PRECONDITION_DEFAULT_OPTIONS: SweepPreconditionOptions = Object.freeze({
const DEFAULT_OPTIONS: SweepPreconditionOptions = {
minNumberOfUtxos: 1,
minConfirmations: JM_TAKER_UTXO_AGE,
maxNumberOfNonFrozenFidelityBondOutputs: 0,
})
}
const isUtxoEligible = (utxo: Utxo): boolean => {
return !utxo.frozen && !fb.utxo.isFidelityBond(utxo)
}
const groupByJarIndex = (utxos: Utxo[]): Map<number, Utxo[]> => {
return utxos.reduce((acc, utxo) => {
@ -64,35 +65,11 @@ export const buildSweepPreconditionSummary = (
options: Partial<SweepPreconditionOptions> = {},
): SweepPreconditionSummary => {
const mergedOptions: SweepPreconditionOptions = {
...SWEEP_PRECONDITION_DEFAULT_OPTIONS,
...DEFAULT_OPTIONS,
...options,
}
const { nonFrozenFbUtxos, eligibleUtxos } = utxos
.filter((it) => it.frozen !== true)
.reduce(
(context, utxo) => {
if (fb.utxo.isFidelityBond(utxo)) {
context.nonFrozenFbUtxos.push(utxo)
} else {
context.eligibleUtxos.push(utxo)
}
return context
},
{ nonFrozenFbUtxos: [] as Utxo[], eligibleUtxos: [] as Utxo[] },
)
if (nonFrozenFbUtxos.length > mergedOptions.maxNumberOfNonFrozenFidelityBondOutputs) {
return {
isFulfilled: false,
options: mergedOptions,
numberOfNonFrozenFidelityBondOutputs: nonFrozenFbUtxos.length,
numberOfMissingUtxos: 0,
numberOfMissingConfirmations: 0,
retryLockedUtxos: [],
}
}
const eligibleUtxos = utxos.filter((utxo) => isUtxoEligible(utxo))
const numberOfMissingUtxos = Math.max(0, mergedOptions.minNumberOfUtxos - eligibleUtxos.length)
const numberOfMissingConfirmations =
numberOfMissingUtxos > 0 ? 0 : calcMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations)
@ -102,7 +79,6 @@ export const buildSweepPreconditionSummary = (
return {
isFulfilled: numberOfMissingUtxos === 0 && numberOfMissingConfirmations === 0 && retryLockedUtxos.length === 0,
options: mergedOptions,
numberOfNonFrozenFidelityBondOutputs: 0,
numberOfMissingUtxos,
numberOfMissingConfirmations,
retryLockedUtxos,

View file

@ -40,7 +40,6 @@ const buttonVariants = cva(
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
'icon-xxl': 'size-12',
},
},
defaultVariants: {

View file

@ -3,10 +3,8 @@ import '@testing-library/jest-dom/vitest'
import { render as reactRender, screen, type RenderOptions } from '@testing-library/react'
import user from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Balance } from '@/components/ui/jam/Balance'
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
import '@/i18n/config'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import { Balance } from './Balance'
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
const providers = ({ children }: { children: React.ReactNode }) => {
@ -37,13 +35,11 @@ describe('<Balance />', () => {
})
it('should render balance in SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
})
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
})
it('should render a string BTC value correctly as BTC', () => {
@ -53,11 +49,9 @@ describe('<Balance />', () => {
})
it('should render a string BTC value correctly as SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.03224961'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
})
render(<Balance valueString={'123.03224961'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
})
it('should render a zero string BTC value correctly as BTC', () => {
@ -71,31 +65,23 @@ describe('<Balance />', () => {
})
it('should render a large string BTC value correctly as BTC', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
it('should render a large string BTC value correctly as SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
it('should render a max string BTC value correctly as BTC', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
it('should render a max string BTC value correctly as SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
it('should render a string SATS value correctly as SATS', () => {
@ -119,31 +105,23 @@ describe('<Balance />', () => {
})
it('should render a large string SATS value correctly as BTC', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
it('should render a large string SATS value correctly as SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
it('should render a max string SATS value correctly as BTC', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
it('should render a max string SATS value correctly as SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
it('should render frozen balance in BTC', () => {
@ -154,12 +132,10 @@ describe('<Balance />', () => {
})
it('should render frozen balance in SATS', () => {
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
})
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
})
it('should render balance without symbol', () => {
@ -222,51 +198,4 @@ describe('<Balance />', () => {
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should render the visibility toggle as a keyboard-focusable button with an accessible name', () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
const toggleButton = screen.getByRole('button', { name: 'Show balance' })
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveAttribute('aria-pressed', 'true')
})
it('should update the accessible name and aria-pressed after toggling', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.click(screen.getByRole('button', { name: 'Show balance' }))
const toggleButton = screen.getByRole('button', { name: 'Hide balance' })
expect(toggleButton).toBeInTheDocument()
expect(toggleButton).toHaveAttribute('aria-pressed', 'false')
})
it('should toggle visibility via keyboard (Enter)', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.tab()
expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should toggle visibility via keyboard (Space)', async () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} />)
await user.tab()
expect(screen.getByRole('button', { name: 'Show balance' })).toHaveFocus()
await user.keyboard(' ')
expect(screen.getByTestId(`sats-amount`)).toBeInTheDocument()
expect(screen.queryByText(`*****`)).not.toBeInTheDocument()
})
it('should not render a focusable button when the visibility toggle is disabled', () => {
render(<Balance valueString={`21`} convertToUnit="sats" showBalance={false} enableVisibilityToggle={false} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})

View file

@ -1,6 +1,5 @@
import { useEffect, useMemo, useState, type MouseEvent, type MouseEventHandler, type PropsWithChildren } from 'react'
import { SnowflakeIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { useJamDisplayContext } from '@/context/JamDisplayContext'
import { cn, satsToBtc, tryBtcToSat, isValidNumber, getBtcParts, formatSats } from '@/lib/utils'
@ -24,8 +23,6 @@ type ElementWithSymbolsProps = PropsWithChildren<{
frozenSymbol?: boolean
className?: string
onClick?: MouseEventHandler
'aria-label'?: string
'aria-pressed'?: boolean
}>
const ElementWithSymbols = ({
@ -36,40 +33,23 @@ const ElementWithSymbols = ({
className,
children,
onClick,
'aria-label': ariaLabel,
'aria-pressed': ariaPressed,
}: ElementWithSymbolsProps) => {
const sharedClassName = cn(
'balance-hook inline-flex items-center',
{
'text-brand-info': frozen,
},
className,
)
const content = (
<>
return (
<span
className={cn(
'balance-hook inline-flex items-center',
{
'text-brand-info': frozen,
},
className,
)}
onClick={onClick}
>
{children}
{showSymbol && symbol}
{frozen && frozenSymbol && FROZEN_SYMBOL}
</>
</span>
)
if (onClick) {
return (
<button
type="button"
className={cn(sharedClassName, 'appearance-none border-0 bg-transparent p-0')}
onClick={onClick}
aria-label={ariaLabel}
aria-pressed={ariaPressed}
>
{content}
</button>
)
}
return <span className={sharedClassName}>{content}</span>
}
const DECIMAL_POINT_CHAR = '.'
@ -176,7 +156,6 @@ export const BalanceComponent = ({
enableVisibilityToggle,
...props
}: BalanceComponentProps) => {
const { t } = useTranslation()
const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
const displayMode = useMemo<DisplayMode>(() => {
return isBalanceVisible ? (convertToUnit ?? 'default') : 'hidden'
@ -194,25 +173,18 @@ export const BalanceComponent = ({
setIsBalanceVisible((current) => !current)
}
const onClickHandler = enableVisibilityToggle === false ? undefined : toggleVisibility
const isInteractive = Boolean(onClickHandler || props.onClick)
return {
...props,
className: cn(props.className, {
'cursor-pointer': isInteractive,
'cursor-pointer': onClickHandler || props.onClick,
}),
onClick: isInteractive
? (event: MouseEvent<HTMLButtonElement>) => {
onClickHandler?.(event)
props.onClick?.(event)
}
: undefined,
'aria-label': onClickHandler
? t(isBalanceVisible ? 'settings.hide_balance' : 'settings.show_balance')
: undefined,
'aria-pressed': onClickHandler ? !isBalanceVisible : undefined,
onClick: (event: MouseEvent<HTMLSpanElement>) => {
onClickHandler?.(event)
props.onClick?.(event)
},
}
}, [props, enableVisibilityToggle, isBalanceVisible, t])
}, [props, enableVisibilityToggle])
const element = useMemo(() => {
if (displayMode === 'hidden') {

View file

@ -1,163 +0,0 @@
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { cn } from '@/lib/utils'
export interface CookingPotIconProps {
sourceJarIndex?: number
destinationJarIndex?: number
className?: string
}
const STROKE_CLASSES: Record<string, string> = {
jar0: 'stroke-jar0',
jar1: 'stroke-jar1',
jar2: 'stroke-jar2',
jar3: 'stroke-jar3',
jar4: 'stroke-jar4',
jarUnknown: 'stroke-jar-unknown',
}
export const CookingPotIcon = ({ sourceJarIndex, destinationJarIndex, className }: CookingPotIconProps) => {
const sourceVariant = jarBadgeVariant(sourceJarIndex)
const destinationVariant = jarBadgeVariant(destinationJarIndex) ?? sourceVariant
const sourceStrokeClass = (sourceVariant && STROKE_CLASSES[sourceVariant]) || 'stroke-jar-unknown'
const destinationStrokeClass = (destinationVariant && STROKE_CLASSES[destinationVariant]) || sourceStrokeClass
return (
<div className={cn('relative flex flex-col items-center justify-center select-none', className)}>
<style>{`
@keyframes lidRattle {
0%, 100% { transform: rotate(0deg); }
20% { transform: rotate(-2.5deg) translateY(-1px); }
40% { transform: rotate(1deg); }
60% { transform: rotate(-1.5deg) translateY(-0.5px); }
80% { transform: rotate(0.5deg); }
}
@keyframes waveShift {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
@keyframes steamRise {
0% { transform: translateY(0) scaleY(0.9); opacity: 0.15; }
50% { opacity: 0.75; }
100% { transform: translateY(-12px) scaleY(1.1); opacity: 0; }
}
.anim-lid {
animation: lidRattle 2.2s ease-in-out infinite;
transform-origin: 35px 50px;
}
.anim-wave {
animation: waveShift 2.8s ease-in-out infinite;
}
.anim-steam-1 { animation: steamRise 2.2s ease-in-out infinite; }
.anim-steam-2 { animation: steamRise 2.6s ease-in-out 0.7s infinite; }
.anim-steam-3 { animation: steamRise 2.4s ease-in-out 1.2s infinite; }
`}</style>
<div className="relative z-10 flex size-44 items-center justify-center md:size-48">
<svg viewBox="0 0 200 160" className="size-full select-none" aria-hidden="true">
<defs>
<pattern
id="potDiagonalHatch"
width="10"
height="10"
patternUnits="userSpaceOnUse"
patternTransform="rotate(45)"
>
<line x1="0" y1="0" x2="0" y2="10" className={sourceStrokeClass} strokeWidth="2.5" />
<line x1="5" y1="0" x2="5" y2="10" className={destinationStrokeClass} strokeWidth="2.5" />
</pattern>
<clipPath id="potBodyClip">
<path d="M 35 50 V 122 C 35 132 43 140 53 140 H 147 C 157 140 165 132 165 122 V 50 Z" />
</clipPath>
</defs>
<g className="text-foreground">
<g className="text-foreground/60">
<path
d="M 125 35 Q 118 24 126 14 T 120 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="anim-steam-1"
/>
<path
d="M 148 30 Q 142 20 150 10 T 144 -2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="anim-steam-2"
/>
<path
d="M 102 42 Q 96 30 104 18 T 98 5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
className="anim-steam-3"
/>
</g>
<path
d="M 35 60 H 22 C 14 60 14 80 22 80 H 35"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M 165 60 H 178 C 186 60 186 80 178 80 H 165"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g clipPath="url(#potBodyClip)">
<g className="anim-wave">
<path
d="M 25 78 C 55 58 75 70 100 88 C 120 102 145 106 175 96 L 175 150 L 25 150 Z"
fill="url(#potDiagonalHatch)"
/>
</g>
</g>
<path
d="M 35 50 V 122 C 35 132 43 140 53 140 H 147 C 157 140 165 132 165 122 V 50 Z"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<g className="anim-lid">
<path
d="M 87 27 C 87 20 107 20 107 27"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
/>
<path
d="M 35 50 C 31 40 38 34 44 34 L 158 20 C 164 19 168 24 165 30 L 35 50"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
</g>
</svg>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more