mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
Compare commits
56 commits
v2.0.0-bet
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1abd6c7e6 | ||
|
|
0b707f44fc | ||
|
|
69423b6b1d | ||
|
|
d406a18456 | ||
|
|
047a8ab779 | ||
|
|
d9918b30b0 | ||
|
|
04c62c12fd | ||
|
|
c832b80a2f | ||
|
|
1535a8148e | ||
|
|
6022ed3d41 | ||
|
|
8f9d8d5917 | ||
|
|
56541c5c29 | ||
|
|
b9f2730a72 | ||
|
|
037bf95784 | ||
|
|
152793a11b | ||
|
|
8eef1958a5 | ||
|
|
28888dbb6d | ||
|
|
d0326db3e7 | ||
|
|
9fb32af42f | ||
|
|
4c2a886c6b | ||
|
|
4a90f342ff | ||
|
|
c70a8892a6 | ||
|
|
51a23b4c5a | ||
|
|
1c23525da5 | ||
|
|
471442ad80 | ||
|
|
1a22e97cfb | ||
|
|
6d7b0362e3 | ||
|
|
25246e1690 | ||
|
|
edf97c30a7 | ||
|
|
f7f4de6ab3 | ||
|
|
0c77dc37aa | ||
|
|
7988595766 | ||
|
|
2b69ef3dbb | ||
|
|
150e880040 | ||
|
|
d8e6d6aa15 | ||
|
|
e0d7479711 | ||
|
|
02115e808a | ||
|
|
9346804bf8 | ||
|
|
306e6b83d1 | ||
|
|
59e959da97 | ||
|
|
bce1df33e9 | ||
|
|
d5b38740c7 | ||
|
|
864e976772 | ||
|
|
ff0a9f229e | ||
|
|
6490a96714 | ||
|
|
0e24250e3d | ||
|
|
01a952ff63 | ||
|
|
fc1a02cead | ||
|
|
82cc4762ff | ||
|
|
8789e63798 | ||
|
|
3e7f5ea41f | ||
|
|
c41b40bec9 | ||
|
|
fe43741a37 | ||
|
|
3f0d3bbc40 | ||
|
|
5f0818d734 | ||
|
|
6698c6d574 |
158 changed files with 8243 additions and 2816 deletions
5
.env
5
.env
|
|
@ -1,5 +1,5 @@
|
|||
VITE_JAM_DEFAULT_THEME=dark
|
||||
VITE_JAM_REPO_URL=https://github.com/joinmarket-webui
|
||||
VITE_JAM_REPO_URL=https://github.com/joinmarket-webui/jam
|
||||
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,3 +23,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -13,3 +13,5 @@ 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
|
||||
|
|
|
|||
|
|
@ -3,3 +3,5 @@ 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
|
||||
|
|
|
|||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
|
@ -11,14 +11,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: ['v26.3.0']
|
||||
node-version: ['v26.5.0']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup (Node.js ${{ matrix.node-version }})
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
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@v5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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@v2
|
||||
uses: davelosert/vitest-coverage-report-action@8b157684c6a6b259b97d45e72b44242865c0f6a5 # v2.12.2
|
||||
|
|
|
|||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -41,7 +41,9 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
|
|
|||
6
.github/workflows/storybook-build.yml
vendored
6
.github/workflows/storybook-build.yml
vendored
|
|
@ -12,10 +12,12 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
|
|
|
|||
10
.github/workflows/storybook-pages.yml
vendored
10
.github/workflows/storybook-pages.yml
vendored
|
|
@ -24,10 +24,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
|
|
@ -38,13 +38,13 @@ jobs:
|
|||
run: npm run storybook:build-gh-pages
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@v6
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: storybook-static
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
|
|
|||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
v26.3.0
|
||||
v26.5.0
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
import { Suspense, useEffect } from 'react'
|
||||
import type { Preview } from '@storybook/react-vite'
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon'
|
||||
import { mswLoader } from 'msw-storybook-addon/csf3'
|
||||
import { setupWorker } from 'msw/browser'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
|
|
@ -17,15 +18,6 @@ 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' },
|
||||
|
|
@ -85,12 +77,28 @@ 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],
|
||||
loaders: [
|
||||
mswLoader(async () => {
|
||||
const worker = setupWorker()
|
||||
await worker.start({
|
||||
onUnhandledRequest: 'bypass',
|
||||
serviceWorker: {
|
||||
url: mswServiceWorkerUrl,
|
||||
},
|
||||
quiet: true,
|
||||
})
|
||||
return worker
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const withTheme = (Story: React.ComponentType, context: GlobalContext) => {
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ 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
|
||||
|
|
@ -124,6 +126,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -156,6 +156,8 @@ 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:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ 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 --unmatured --blocks 3
|
||||
. "$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
|
||||
|
||||
# fund wallet in ng standalone secondary container
|
||||
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50
|
||||
|
|
@ -37,7 +39,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 = 106
|
||||
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = at least 106 blocks
|
||||
. "$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
|
||||
|
|
|
|||
|
|
@ -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=8080 \
|
||||
JMOBWATCH_PORT=8000 \
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
|
|
|||
3050
package-lock.json
generated
3050
package-lock.json
generated
File diff suppressed because it is too large
Load diff
108
package.json
108
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "jam",
|
||||
"version": "2.0.0-beta.1",
|
||||
"version": "2.0.0-beta.2",
|
||||
"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.4.0",
|
||||
"@hookform/resolvers": "5.7.1",
|
||||
"@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0",
|
||||
"@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",
|
||||
"@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",
|
||||
"@scure/bip32": "2.2.0",
|
||||
"@scure/bip39": "2.2.0",
|
||||
"@tailwindcss/vite": "4.3.2",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tanstack/match-sorter-utils": "8.19.4",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@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.24.0",
|
||||
"lucide-react": "1.30.0",
|
||||
"next-themes": "0.4.6",
|
||||
"qrcode": "1.5.4",
|
||||
"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",
|
||||
"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",
|
||||
"react-use-websocket": "4.13.0",
|
||||
"sonner": "2.0.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwindcss": "4.3.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"yup": "1.7.1",
|
||||
"zustand": "5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@testing-library/user-event": "14.6.3",
|
||||
"@trivago/prettier-plugin-sort-imports": "6.0.2",
|
||||
"@types/node": "26.1.1",
|
||||
"@types/node": "26.1.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"@vitejs/plugin-react": "6.0.5",
|
||||
"@vitest/browser": "4.1.10",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"conventional-changelog": "8.1.0",
|
||||
"eslint": "9.39.4",
|
||||
"conventional-changelog": "8.1.1",
|
||||
"eslint": "9.39.5",
|
||||
"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.0",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-unicorn": "65.0.1",
|
||||
"globals": "17.7.0",
|
||||
"globals": "17.9.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"lint-staged": "17.0.8",
|
||||
"jsdom": "30.0.1",
|
||||
"lint-staged": "17.3.0",
|
||||
"msw": "2.15.0",
|
||||
"msw-storybook-addon": "2.0.7",
|
||||
"prettier": "3.9.5",
|
||||
"prettier-plugin-tailwindcss": "0.8.0",
|
||||
"storybook": "10.5.0",
|
||||
"msw-storybook-addon": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"prettier-plugin-tailwindcss": "0.8.1",
|
||||
"storybook": "10.5.7",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"vite": "8.1.4",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"vite": "8.2.1",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ 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'
|
||||
|
|
@ -240,6 +241,7 @@ function App() {
|
|||
path={routes.walletJarsDetails}
|
||||
element={<WalletJarsDetailsPage walletFileName={walletFileName!} />}
|
||||
/>
|
||||
<Route path={routes.txHistory} element={<TxHistoryPage walletFileName={walletFileName!} />} />
|
||||
{isDeveloperMode && isDebugFeatureEnabled('devPage') && (
|
||||
<Route
|
||||
id="dev-page"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
useJars: () => ({ jars }),
|
||||
}))
|
||||
|
||||
vi.mock('@/store/jamSettingsStore', () => ({
|
||||
usePreviewFeatures: () => ({
|
||||
'tx-history': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
|
||||
WalletJarsDetailsOverlay: ({
|
||||
open,
|
||||
|
|
@ -53,7 +59,11 @@ vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('./ui/jam/Balance', () => ({
|
||||
vi.mock('./wallet/TxHistoryContent', () => ({
|
||||
TxHistoryContent: () => <div data-testid="TxHistoryContent" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
|
||||
<span onClick={onClick}>{valueString}</span>
|
||||
),
|
||||
|
|
@ -74,6 +84,7 @@ const makeJar = (jarIndex: number, name: string): Jar =>
|
|||
calculatedTotalBalanceInSats: 100,
|
||||
calculatedAvailableBalanceInSats: 100,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
},
|
||||
}) as unknown as Jar
|
||||
|
||||
|
|
@ -124,10 +135,14 @@ describe('MainWalletPage', () => {
|
|||
expect(refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a jars spinner while fetching and refreshes on click', () => {
|
||||
it('keeps jars visible and clickable while fetching in the background', () => {
|
||||
walletInfo = { ...walletInfo, isFetching: true }
|
||||
render(<MainWalletPage walletFileName={walletFileName} />)
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument()
|
||||
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))
|
||||
fireEvent.click(screen.getByText('global.refresh'))
|
||||
expect(refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -137,4 +152,17 @@ 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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
|
||||
import { ChevronDownIcon, 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,8 +17,11 @@ 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 {
|
||||
|
|
@ -28,8 +31,11 @@ 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()
|
||||
|
|
@ -55,6 +61,10 @@ 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}>
|
||||
|
|
@ -87,8 +97,37 @@ 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>
|
||||
|
|
@ -116,7 +155,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
|
|||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex min-h-[128px] items-center justify-center gap-4">
|
||||
{isFetching ? (
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 py-8">
|
||||
<Spinner className="motion-reduce:hidden" />
|
||||
{t('global.loading')}
|
||||
|
|
|
|||
|
|
@ -53,16 +53,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onClick,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onClick?: () => void
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
|
||||
<button onSelect={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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'
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
|
|||
jarIndex,
|
||||
name: `Jar ${jarIndex}`,
|
||||
color: '#808080',
|
||||
balanceSummary: {},
|
||||
balanceSummary: BALANCE_SUMMARY_EMPTY,
|
||||
utxos,
|
||||
}) as unknown as Jar
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@ 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 type { WalletFileName } from '@/lib/utils'
|
||||
import { delayedPromise, type WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
import { useFidelityBondMutations } from '../fidelity-bond/useFidelityBondMutations'
|
||||
|
|
@ -106,10 +110,8 @@ export function useCreateFidelityBondWizard(
|
|||
|
||||
const jarsWithUtxos = useMemo(() => {
|
||||
return walletInfo.jars.filter((jar) => {
|
||||
// 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',
|
||||
const availableUtxos = jar.utxos.filter((utxo) =>
|
||||
fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status),
|
||||
)
|
||||
return availableUtxos.length > 0
|
||||
})
|
||||
|
|
@ -117,17 +119,9 @@ export function useCreateFidelityBondWizard(
|
|||
|
||||
const availableUtxos = useMemo(() => {
|
||||
if (selectedJar === undefined) return []
|
||||
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)
|
||||
)
|
||||
return selectedJar.utxos
|
||||
.filter((utxo) => fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status))
|
||||
.toSorted((a, b) => b.value - a.value)
|
||||
}, [selectedJar, walletInfo.addressSummary])
|
||||
|
||||
const selectedUtxos = useMemo(() => {
|
||||
|
|
@ -283,17 +277,15 @@ 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,
|
||||
amount_sats: 0, // 0 := sweep!
|
||||
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) {
|
||||
|
|
@ -301,12 +293,33 @@ export function useCreateFidelityBondWizard(
|
|||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
throwOnError: true,
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// only debug output
|
||||
console.debug('Error while unfreezing previously frozen UTXO.')
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -316,8 +329,9 @@ export function useCreateFidelityBondWizard(
|
|||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
} catch (_ignoredOnPurpose: unknown) {
|
||||
// only debug output
|
||||
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
|
||||
}
|
||||
}
|
||||
setFrozenUtxos([])
|
||||
|
|
|
|||
|
|
@ -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 { Resolver, SubmitHandler } from 'react-hook-form'
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as yup from 'yup'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -179,8 +179,7 @@ export function EarnForm({
|
|||
} = useForm<EarnFormValues, unknown, EarnFormValues>({
|
||||
mode: 'onSubmit',
|
||||
defaultValues: FORM_INPUT_DEFAULT_VALUES,
|
||||
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
|
||||
resolver: yupResolver(schema) as Resolver<EarnFormValues, unknown, EarnFormValues>,
|
||||
resolver: yupResolver(schema),
|
||||
})
|
||||
|
||||
const values = useWatch({ control })
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ 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 type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import * as JAM from '@/constants/jam'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import type { EarnFormValues } from './EarnForm'
|
||||
import { EarnPage } from './EarnPage'
|
||||
|
|
@ -10,6 +12,12 @@ 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: {
|
||||
|
|
@ -26,17 +34,12 @@ const mocks = vi.hoisted(() => ({
|
|||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
walletInfo: {
|
||||
addressSummary: {},
|
||||
fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] },
|
||||
hasEligibleFidelityBondUtxo: true,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
jars: [] as Array<{
|
||||
balanceSummary: {
|
||||
calculatedAvailableBalanceInSats: number
|
||||
calculatedConfirmedAvailableBalanceInSats: number
|
||||
calculatedFrozenOrLockedBalanceInSats: number
|
||||
calculatedTotalBalanceInSats: number
|
||||
}
|
||||
}>,
|
||||
jars: [] as Jar[],
|
||||
maxJarAvailableBalance: 100_000_000,
|
||||
},
|
||||
}))
|
||||
|
|
@ -76,15 +79,26 @@ vi.mock('@tanstack/react-query', () => ({
|
|||
reset: vi.fn(),
|
||||
}
|
||||
}),
|
||||
useQuery: vi.fn(() => ({
|
||||
refetch: mocks.stopMakerRefetch,
|
||||
})),
|
||||
useQuery: vi.fn(() => {
|
||||
return { refetch: mocks.stopMakerRefetch }
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useQueryOrderbook', () => ({
|
||||
useQueryOrderbook: (options: unknown) => {
|
||||
mocks.orderbookQueryOptions(options)
|
||||
return {
|
||||
queryResult: {
|
||||
data: mocks.orderbookData(),
|
||||
...mocks.orderbookQueryState,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ children, i18nKey }: { children?: React.ReactNode; i18nKey?: string }) => (
|
||||
<span>{children ?? i18nKey}</span>
|
||||
),
|
||||
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),
|
||||
}),
|
||||
|
|
@ -166,6 +180,7 @@ vi.mock('./EarnForm', () => ({
|
|||
<div>
|
||||
earn-form:{String(disabled)}:{String(debug)}
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
void onSubmit({
|
||||
offerMinAmount: 50_000,
|
||||
|
|
@ -194,9 +209,24 @@ vi.mock('./MoveToJarDialog', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('./OfferCard', () => ({
|
||||
OfferCard: ({ children, nickname }: { children?: React.ReactNode; nickname?: string }) => (
|
||||
OfferCard: ({
|
||||
children,
|
||||
nickname,
|
||||
orderbookStatus,
|
||||
orderbookOffer,
|
||||
fidelityBond,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
nickname?: string
|
||||
orderbookStatus?: string
|
||||
orderbookOffer?: { fidelity_bond_value?: number }
|
||||
fidelityBond?: { amount?: number }
|
||||
}) => (
|
||||
<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>
|
||||
),
|
||||
|
|
@ -212,22 +242,49 @@ vi.mock('./report/EarnReportOverlay', () => ({
|
|||
|
||||
const balanceSummary = {
|
||||
calculatedAvailableBalanceInSats: 100_000_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedConfirmedAvailableBalanceInSats: 100_000_000,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 100_000_000,
|
||||
}
|
||||
|
||||
const expiredBond = {
|
||||
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 = {
|
||||
address: 'bc1qbond',
|
||||
confirmations: 12,
|
||||
frozen: false,
|
||||
label: '',
|
||||
locktime: '1970-01-01 00:00:00',
|
||||
locktime: '2000-01-01 00:00:00',
|
||||
path: "m/84'/1'/0':1",
|
||||
tries_remaining: 3,
|
||||
utxo: 'bond-tx:0',
|
||||
value: 50_000,
|
||||
} as unknown as FidelityBondUtxo
|
||||
tries: 3,
|
||||
external: false,
|
||||
mixdepth: 0,
|
||||
}
|
||||
|
||||
const setSession = (overrides: Record<string, unknown> = {}) => {
|
||||
jmSessionStore.setState({
|
||||
|
|
@ -248,6 +305,11 @@ 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({})
|
||||
|
|
@ -261,10 +323,14 @@ 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 = [{ balanceSummary }]
|
||||
mocks.walletInfo.jars = [makeJar()]
|
||||
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
|
||||
setSession()
|
||||
})
|
||||
|
|
@ -275,21 +341,22 @@ describe('EarnPage', () => {
|
|||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('page-loading')).toBeInTheDocument()
|
||||
|
||||
expect(screen.queryByText('submit-earn')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('starts earning and opens fee/report dialogs', async () => {
|
||||
it('starts earning', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.feeConfigMissing = true
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
await user.click(screen.getByText('open-fee-config'))
|
||||
expect(screen.getByText('fee-config-dialog:true')).toBeInTheDocument()
|
||||
expect(screen.queryByText('earn.precondition.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'earn.button_stop' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'earn.button_show_report' }))
|
||||
expect(screen.getByText('earn-report:true')).toBeInTheDocument()
|
||||
expect(screen.getByText('submit-earn')).toBeEnabled()
|
||||
|
||||
await user.click(screen.getByText('submit-earn'))
|
||||
|
||||
await waitFor(() => expect(mocks.startMaker).toHaveBeenCalled())
|
||||
expect(mocks.startMaker).toHaveBeenCalledWith({
|
||||
body: {
|
||||
|
|
@ -304,6 +371,21 @@ 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({
|
||||
|
|
@ -313,13 +395,61 @@ describe('EarnPage', () => {
|
|||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
expect(screen.getByText('offer-card:maker-a')).toBeInTheDocument()
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -328,63 +458,212 @@ describe('EarnPage', () => {
|
|||
expect(screen.getByText('earn.alert_waiting_start_title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens fidelity bond actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('display debug output in developer mode', () => {
|
||||
mocks.developerMode = true
|
||||
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()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_spend/u }))
|
||||
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.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)
|
||||
expect(screen.getByText('move-to-jar-dialog:true')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u }))
|
||||
const renewButton = screen.getByRole('button', { name: /earn\.fidelity_bond\.existing\.button_renew/u })
|
||||
expect(renewButton).toBeEnabled()
|
||||
|
||||
await user.click(renewButton)
|
||||
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 = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
|
||||
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
|
||||
mocks.walletInfo.maxJarAvailableBalance = 100_000_000
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
expect(screen.getByText('earn.alert_unconfirmed_balance_title')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('earn.precondition.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.precondition.hint_missing_confirmations:{"count":1}')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('submit-earn')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('warns when there is no spendable balance at all', () => {
|
||||
mocks.walletInfo.jars = [{ balanceSummary: { ...balanceSummary, calculatedConfirmedAvailableBalanceInSats: 0 } }]
|
||||
mocks.walletInfo.jars = [makeJar({ calculatedConfirmedAvailableBalanceInSats: 0 })]
|
||||
mocks.walletInfo.maxJarAvailableBalance = 0
|
||||
|
||||
render(<EarnPage walletFileName="wallet.jmdat" />)
|
||||
expect(screen.getByText('earn.alert_no_spendable_balance_title')).toBeInTheDocument()
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
|
|
@ -392,7 +671,12 @@ 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())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,15 +2,7 @@ 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 {
|
||||
AlertTriangleIcon,
|
||||
FileTextIcon,
|
||||
HourglassIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ShuffleIcon,
|
||||
UnlockIcon,
|
||||
} from 'lucide-react'
|
||||
import { FileTextIcon, HourglassIcon, InfoIcon, 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'
|
||||
|
|
@ -24,13 +16,16 @@ 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'
|
||||
|
|
@ -38,25 +33,16 @@ 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
|
||||
|
|
@ -75,36 +61,7 @@ interface EarnPageProps {
|
|||
walletFileName: WalletFileName
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
const JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED = isDevMode()
|
||||
|
||||
export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -118,12 +75,6 @@ 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>()
|
||||
|
|
@ -133,6 +84,34 @@ 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,
|
||||
|
|
@ -142,7 +121,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
const stopMakerQuery = useQuery({
|
||||
...stopMakerQueryOptions,
|
||||
queryFn: withQueryDelay(stopMakerQueryOptions.queryFn, {
|
||||
delayAfter: MAKER_STOP_RESPONSE_DELAY,
|
||||
delayAfter: JAM.MAKER_STOP_RESPONSE_DELAY,
|
||||
}),
|
||||
enabled: false,
|
||||
retry: false,
|
||||
|
|
@ -182,10 +161,38 @@ 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: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
})
|
||||
|
||||
const onStop = async () => {
|
||||
|
|
@ -264,6 +271,18 @@ 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 />
|
||||
|
|
@ -276,6 +295,9 @@ 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 ? (
|
||||
|
|
@ -294,21 +316,23 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
<Card
|
||||
className={cn({
|
||||
hidden: jmSession.maker_running && !waitingForOfferUpdate,
|
||||
'blur-[2px]': isWaitingMakerStop || waitingForOfferUpdate,
|
||||
'blur-[2px]':
|
||||
isWaitingMakerStop ||
|
||||
waitingForOfferUpdate ||
|
||||
jmSession.maker_running ||
|
||||
jmSession.coinjoin_in_process ||
|
||||
jmSession.rescanning,
|
||||
})}
|
||||
>
|
||||
<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={
|
||||
earnBalanceWarning !== null ||
|
||||
earnPreconditionWarning !== undefined ||
|
||||
walletInfo.isFetching ||
|
||||
isWaitingMakerStart ||
|
||||
isWaitingMakerStop ||
|
||||
|
|
@ -322,7 +346,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
</Card>
|
||||
|
||||
{/* Fidelity Bonds */}
|
||||
{walletInfo.fidelityBondSummary.fbOutputs.length === 0 ? (
|
||||
{!hasFidelityBond ? (
|
||||
<div
|
||||
className={cn('mt-8', {
|
||||
hidden: jmSession.maker_running || waitingForMakerUpdate || waitingForOfferUpdate,
|
||||
|
|
@ -337,8 +361,29 @@ 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" onClick={() => setShowCreateFidelityBondDialog(true)}>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!isCreateFidelityBondEnabled}
|
||||
onClick={() => setShowCreateFidelityBondDialog(true)}
|
||||
>
|
||||
{t('earn.fidelity_bond.create_form.button_create')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
|
|
@ -354,14 +399,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
<div className="space-y-4">
|
||||
{walletInfo.fidelityBondSummary.fbOutputs.map((it) => {
|
||||
const isExpired = !fb.utxo.isLocked(it)
|
||||
const actionsEnabled =
|
||||
isExpired &&
|
||||
!jmSession.rescanning &&
|
||||
!jmSession.maker_running &&
|
||||
!jmSession.coinjoin_in_process &&
|
||||
!waitingForMakerUpdate &&
|
||||
!waitingForOfferUpdate &&
|
||||
!walletInfo.isFetching
|
||||
const actionsEnabled = isExpired && isFidelityBondActionsEnabled
|
||||
return (
|
||||
<FidelityBondCard value={it} key={it.utxo}>
|
||||
{isExpired && (
|
||||
|
|
@ -390,10 +428,14 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Existing bonds hide the create button — expose it in developer mode to test multiple bonds */}
|
||||
{isDeveloperMode && (
|
||||
|
||||
{showCreateAdditionalFidelityBond && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setShowCreateFidelityBondDialog(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!isCreateFidelityBondEnabled}
|
||||
onClick={() => setShowCreateFidelityBondDialog(true)}
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('earn.fidelity_bond.create_form.button_create')}
|
||||
</Button>
|
||||
|
|
|
|||
77
src/components/earn/EarnPreconditionAlerts.tsx
Normal file
77
src/components/earn/EarnPreconditionAlerts.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value?: string }) => <span data-testid="address">{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -2,18 +2,19 @@ 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 { time } from '@/lib/utils'
|
||||
import { cn, 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, children }: PropsWithChildren<FidelityBondCardProps>) {
|
||||
export function FidelityBondCard({ value, className, children }: PropsWithChildren<FidelityBondCardProps>) {
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const isExpired = !fb.utxo.isLocked(value)
|
||||
|
|
@ -31,7 +32,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isExpired ? (
|
||||
|
|
|
|||
17
src/components/earn/MoveToJarDialog.schema.test.ts
Normal file
17
src/components/earn/MoveToJarDialog.schema.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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)
|
||||
},
|
||||
)
|
||||
})
|
||||
13
src/components/earn/MoveToJarDialog.schema.ts
Normal file
13
src/components/earn/MoveToJarDialog.schema.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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()
|
||||
|
|
@ -4,17 +4,20 @@ 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(() => ({
|
||||
queryReturn: {
|
||||
fetchAddressQueryData: {
|
||||
data: undefined as { address?: string } | undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
},
|
||||
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
|
||||
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
|
||||
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
|
||||
|
|
@ -27,13 +30,16 @@ vi.mock('react-i18next', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => h.queryReturn,
|
||||
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
|
||||
useQuery: () => h.fetchAddressQueryData,
|
||||
useMutation: vi.fn((options: MutationOptions) => ({
|
||||
isPending: false,
|
||||
mutateAsync: async (input: unknown) => await options.mutationFn(input),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({})),
|
||||
freezeMutation: vi.fn(() => ({})),
|
||||
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
|
||||
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
|
||||
getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
|
||||
}))
|
||||
|
||||
|
|
@ -72,16 +78,16 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onClick,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onClick?: () => void
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
|
||||
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
@ -146,8 +152,9 @@ const goToConfirm = () => {
|
|||
|
||||
describe('MoveToJarDialog', () => {
|
||||
beforeEach(() => {
|
||||
h.queryReturn = { data: { address: 'bc111' }, isLoading: false, isError: false }
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
|
||||
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.isPending = false
|
||||
h.refetch = vi.fn()
|
||||
h.extraUtxos = []
|
||||
|
|
@ -169,14 +176,14 @@ describe('MoveToJarDialog', () => {
|
|||
})
|
||||
|
||||
it('shows a loading indicator while the destination address loads', () => {
|
||||
h.queryReturn = { data: undefined, isLoading: true, isError: false }
|
||||
h.fetchAddressQueryData = { 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.queryReturn = { data: undefined, isLoading: false, isError: true }
|
||||
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
expect(screen.getByText('global.errors.error_loading_address_failed')).toBeInTheDocument()
|
||||
|
|
@ -200,12 +207,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.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
|
||||
const calls = h.freezeAsync.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.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
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'
|
||||
|
|
@ -11,7 +13,7 @@ import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
|||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import type { JarIndex } from '@/types/global'
|
||||
import { createMoveToJarFormSchema, type MoveToJarFormValues } from './MoveToJarDialog.schema'
|
||||
import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout'
|
||||
import {
|
||||
AddressPreview,
|
||||
|
|
@ -45,9 +47,25 @@ 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,
|
||||
|
|
@ -77,7 +95,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
|
||||
const handleReset = () => {
|
||||
setStep('select_jar')
|
||||
setSelectedJarIndex(undefined)
|
||||
reset()
|
||||
setTxResult(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
|
|
@ -89,17 +107,24 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (selectedJarIndex === undefined || !destinationAddress) return
|
||||
const submitMove = handleSubmit(async () => {
|
||||
if (!destinationAddress) return
|
||||
|
||||
setStep('sending')
|
||||
const swept = await sweep(destinationAddress, (result) => {
|
||||
setTxResult(result)
|
||||
const txResult = await sweep({
|
||||
destination: destinationAddress,
|
||||
tryFreezeAfterBroadcast: false,
|
||||
})
|
||||
|
||||
setTxResult(txResult)
|
||||
|
||||
if (txResult) {
|
||||
setStep('success')
|
||||
toast.success(t('earn.fidelity_bond.move.success_text'))
|
||||
})
|
||||
if (!swept) setStep('confirm')
|
||||
}
|
||||
} else {
|
||||
setStep('confirm')
|
||||
}
|
||||
})
|
||||
|
||||
const renderFooter = () => {
|
||||
switch (step) {
|
||||
|
|
@ -117,9 +142,9 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
<WizardStepFooter
|
||||
onBack={() => setStep('select_jar')}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => void handleSubmit()}
|
||||
onPrimary={() => void submitMove()}
|
||||
primaryDisabled={!destinationAddress}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isSubmitting}
|
||||
primaryLabel={t('earn.fidelity_bond.move.text_button_submit')}
|
||||
/>
|
||||
)
|
||||
|
|
@ -150,7 +175,13 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
|
|||
|
||||
<FidelityBondJarSelector
|
||||
selectedJarIndex={selectedJarIndex}
|
||||
onSelect={setSelectedJarIndex}
|
||||
onSelect={(jarIndex) =>
|
||||
setValue('destinationJarIndex', jarIndex, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
isJarDisabled={() => false}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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]
|
||||
|
|
@ -18,7 +19,7 @@ vi.mock('@/lib/utils', () => ({
|
|||
isRelativeOffer: (type: string) => type === 'sw0relo',
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
@ -79,4 +80,41 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import type { PropsWithChildren } from 'react'
|
||||
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { FingerprintIcon, HandCoinsIcon, Maximize2Icon, Minimize2Icon } from 'lucide-react'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
CircleCheckIcon,
|
||||
FingerprintIcon,
|
||||
HandCoinsIcon,
|
||||
Maximize2Icon,
|
||||
Minimize2Icon,
|
||||
PickaxeIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
} 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'
|
||||
|
|
@ -36,16 +47,50 @@ interface OfferCardProps {
|
|||
className?: string
|
||||
value: Offer
|
||||
nickname: SessionResponse['nickname']
|
||||
orderbookStatus?: 'checking' | 'visible' | 'missing' | 'error'
|
||||
orderbookOffer?: OrderbookOffer
|
||||
fidelityBond?: OrderbookFidelityBond
|
||||
}
|
||||
|
||||
export function OfferCard({ className, value, nickname, children }: PropsWithChildren<OfferCardProps>) {
|
||||
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>) {
|
||||
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('w-full', className)}>
|
||||
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('earn.current.text_offer')}</CardTitle>
|
||||
<CardDescription></CardDescription>
|
||||
<CardDescription>
|
||||
{offerInOrderbookStatus && (
|
||||
<Badge className="whitespace-normal" variant={offerInOrderbookStatus.variant}>
|
||||
<offerInOrderbookStatus.icon className="shrink-0" />
|
||||
{offerInOrderbookStatus.text}
|
||||
</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -100,8 +145,9 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</div>
|
||||
</div>
|
||||
{!!value?.txfee && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<PickaxeIcon className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="font-semibold">{t('earn.current.text_txfee')}</Label>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Balance valueString={String(value?.txfee || '0')} />
|
||||
|
|
@ -109,6 +155,45 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
|
|||
</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>
|
||||
|
|
|
|||
16
src/components/earn/RenewBondDialog.schema.test.ts
Normal file
16
src/components/earn/RenewBondDialog.schema.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
19
src/components/earn/RenewBondDialog.schema.ts
Normal file
19
src/components/earn/RenewBondDialog.schema.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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()
|
||||
|
|
@ -4,13 +4,15 @@ 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(() => ({
|
||||
queryReturn: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
|
||||
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
|
||||
isPending: false,
|
||||
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({}),
|
||||
refetch: vi.fn(),
|
||||
utxoFrozen: false,
|
||||
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
|
||||
|
|
@ -24,13 +26,16 @@ vi.mock('react-i18next', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useQuery: () => h.queryReturn,
|
||||
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
|
||||
useQuery: () => h.fetchAddressQueryData,
|
||||
useMutation: vi.fn((options: MutationOptions) => ({
|
||||
isPending: false,
|
||||
mutateAsync: async (input: unknown) => await options.mutationFn(input),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
|
||||
directsendMutation: vi.fn(() => ({})),
|
||||
freezeMutation: vi.fn(() => ({})),
|
||||
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
|
||||
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
|
||||
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
|
||||
}))
|
||||
|
||||
|
|
@ -176,9 +181,9 @@ const goToConfirm = () => {
|
|||
describe('RenewBondDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
h.queryReturn = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
|
||||
h.isPending = false
|
||||
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.utxoFrozen = false
|
||||
h.extraUtxos = []
|
||||
h.selectHandlers = []
|
||||
|
|
@ -217,27 +222,33 @@ describe('RenewBondDialog', () => {
|
|||
})
|
||||
|
||||
it('shows a loading spinner while the timelock address loads', () => {
|
||||
h.queryReturn = { data: undefined, isLoading: true, isError: false }
|
||||
h.fetchAddressQueryData = { 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.queryReturn = { data: undefined, isLoading: false, isError: true }
|
||||
h.fetchAddressQueryData = { 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()
|
||||
})
|
||||
|
||||
|
|
@ -249,13 +260,40 @@ 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.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
|
||||
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
|
||||
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)
|
||||
})
|
||||
|
||||
it('returns to the confirm step when sending fails', async () => {
|
||||
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
|
||||
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
|
||||
renderDialog()
|
||||
goToConfirm()
|
||||
fireEvent.click(screen.getByTestId('confirm-switch'))
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
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,
|
||||
|
|
@ -44,10 +48,25 @@ 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,
|
||||
|
|
@ -76,8 +95,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
|
||||
const handleReset = () => {
|
||||
setStep('select_date')
|
||||
setSelectedLockdate('')
|
||||
setConfirmationChecked(false)
|
||||
reset(RENEW_BOND_FORM_DEFAULT_VALUES)
|
||||
setTxResult(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
|
|
@ -89,17 +107,24 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const submitRenewal = handleSubmit(async () => {
|
||||
if (!destinationAddress) return
|
||||
|
||||
setStep('sending')
|
||||
const swept = await sweep(destinationAddress, (result) => {
|
||||
setTxResult(result)
|
||||
|
||||
const txResult = await sweep({
|
||||
destination: destinationAddress,
|
||||
tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
|
||||
})
|
||||
|
||||
setTxResult(txResult)
|
||||
if (txResult) {
|
||||
setStep('success')
|
||||
toast.success(t('earn.fidelity_bond.renew.success_text'))
|
||||
})
|
||||
if (!swept) setStep('confirm')
|
||||
}
|
||||
} else {
|
||||
setStep('confirm')
|
||||
}
|
||||
})
|
||||
|
||||
const renderFooter = () => {
|
||||
switch (step) {
|
||||
|
|
@ -117,9 +142,9 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
<WizardStepFooter
|
||||
onBack={() => setStep('select_date')}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
onPrimary={() => void handleSubmit()}
|
||||
onPrimary={() => void submitRenewal()}
|
||||
primaryDisabled={!confirmationChecked || !destinationAddress}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isSubmitting}
|
||||
primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')}
|
||||
/>
|
||||
)
|
||||
|
|
@ -148,7 +173,17 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
<FidelityBondAmount value={utxo.value} className="text-lg" />
|
||||
</InfoCard>
|
||||
|
||||
<LockdateSelect id="renew-lockdate" value={selectedLockdate} onChange={setSelectedLockdate} />
|
||||
<LockdateSelect
|
||||
id="renew-lockdate"
|
||||
value={selectedLockdate ?? ''}
|
||||
onChange={(lockdate) =>
|
||||
setValue('lockdate', lockdate || undefined, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -193,7 +228,13 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
|
|||
<ConfirmationToggle
|
||||
id="renew-confirmation"
|
||||
checked={confirmationChecked}
|
||||
onCheckedChange={setConfirmationChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('confirmationAccepted', checked, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ 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 (
|
||||
|
|
@ -61,6 +62,7 @@ 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>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisab
|
|||
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
|
||||
isSelected={selectedJarIndex === jar.jarIndex}
|
||||
onClick={() => onSelect(jar.jarIndex)}
|
||||
onSelect={() => onSelect(jar.jarIndex)}
|
||||
disabled={isJarDisabled(jar)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,19 @@ 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)),
|
||||
})
|
||||
|
||||
|
|
|
|||
208
src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts
Normal file
208
src/components/earn/fidelity-bond/useFidelityBondSweep.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
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 } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
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 type { WalletFileName } from '@/lib/utils'
|
||||
import { delayedPromise, type WalletFileName } from '@/lib/utils'
|
||||
import { useFidelityBondMutations } from './useFidelityBondMutations'
|
||||
|
||||
/**
|
||||
|
|
@ -22,13 +24,13 @@ export function useFidelityBondSweep({
|
|||
unfreezeErrorKey: string
|
||||
sendErrorKey: string
|
||||
}) {
|
||||
const walletInfo = useJamWalletInfoContext()
|
||||
const { jars, refetch: walletInfoRefetch } = useJamWalletInfoContext()
|
||||
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
|
||||
unfreezeErrorKey,
|
||||
sendErrorKey,
|
||||
})
|
||||
|
||||
const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
|
||||
const sourceJar = 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(() => {
|
||||
|
|
@ -41,69 +43,130 @@ export function useFidelityBondSweep({
|
|||
* before the frozen UTXOs are restored. Returns false when the sweep failed
|
||||
* (after a best-effort rollback).
|
||||
*/
|
||||
const sweep = async (destination: string, onBroadcast: (result: DirectSendResponse) => void): Promise<boolean> => {
|
||||
setError(undefined)
|
||||
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 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)
|
||||
}
|
||||
const frozen: Utxo[] = []
|
||||
let bondWasUnfrozen = false
|
||||
let sweepBroadcasted = false
|
||||
|
||||
if (utxo.frozen) {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
if (utxo.frozen) {
|
||||
await unfreezeUtxo.mutateAsync({
|
||||
path: { walletname: walletFileName },
|
||||
body: { 'utxo-string': u.utxo, freeze: false },
|
||||
body: { 'utxo-string': utxo.utxo, freeze: false },
|
||||
})
|
||||
} catch {
|
||||
// logged via onError
|
||||
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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
|
||||
|
||||
return { sweep, isLoading, error, setError, sourceJar }
|
||||
return { sweep, isLoading: isPending, error, setError, sourceJar }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,18 +118,42 @@ describe('EarnReportContent', () => {
|
|||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders loading and empty report states', () => {
|
||||
it('renders loading and 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: /earn\.report\.text_button_download_csv/u })).toBeDisabled()
|
||||
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()
|
||||
})
|
||||
|
||||
it('summarizes, filters, refreshes, paginates, and exports report rows', async () => {
|
||||
|
|
@ -152,37 +176,78 @@ 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\}/u)).toBeInTheDocument()
|
||||
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('first maker note')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('earn.report.placeholder_search'), {
|
||||
target: { value: 'second' },
|
||||
})
|
||||
expect(screen.getByText(/earn\.report\.text_report_summary_filtered/u)).toBeInTheDocument()
|
||||
|
||||
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.queryByText('first maker note')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('second maker note')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /earn\.report\.text_button_download_csv/u }))
|
||||
const downloadButton = screen.getByRole('button', { name: 'global.download' })
|
||||
expect(downloadButton).toBeEnabled()
|
||||
|
||||
fireEvent.click(downloadButton)
|
||||
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' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '' }))
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: 'global.refresh' })
|
||||
expect(refreshButton).toBeEnabled()
|
||||
|
||||
fireEvent.click(refreshButton)
|
||||
|
||||
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('dev-badge')).toBeInTheDocument()
|
||||
expect(screen.getByText(/earn\.report\.text_report_summary:\{"count":4\}/u)).toBeInTheDocument()
|
||||
expect(screen.getByText('earn.report.text_report_summary:{"count":4}')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@ import {
|
|||
type SortingState,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon } from 'lucide-react'
|
||||
import { DownloadIcon, PlusIcon, RefreshCwIcon, SearchIcon, XIcon } 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 { Input } from '@/components/ui/input'
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
|
||||
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 = visibleRows.map((row) =>
|
||||
const rows = exportRows.map((row) =>
|
||||
[
|
||||
row.original.timestamp.toISOString(),
|
||||
row.original.cjTotalAmount ?? '',
|
||||
|
|
@ -170,7 +170,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url)
|
||||
}, 0)
|
||||
}, [visibleRows])
|
||||
}, [exportRows])
|
||||
|
||||
const earnedTotal = useMemo(() => sumEarned(allEntries, BITCOIN_GENESIS_DATE), [allEntries])
|
||||
|
||||
|
|
@ -212,23 +212,46 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
<EarnReportChart entries={allEntries} />
|
||||
|
||||
{/* Toolbar: search + refresh */}
|
||||
<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 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>
|
||||
<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 === ''
|
||||
|
|
@ -236,7 +259,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
|
|||
: t('earn.report.text_report_summary_filtered', { count: table.getFilteredRowModel().rows.length })}
|
||||
</span>
|
||||
{isDeveloperMode ? (
|
||||
<Button variant="outline" size="sm" onClick={addDemoEntry}>
|
||||
<Button className="min-w-0 shrink overflow-hidden" variant="outline" onClick={addDemoEntry}>
|
||||
<PlusIcon />
|
||||
{t('earn.report.text_button_generate_demo_report')}
|
||||
<DevBadge />
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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'
|
||||
|
||||
|
|
@ -64,24 +65,26 @@ describe('ImportDetailsForm', () => {
|
|||
})
|
||||
|
||||
it('warns and does not submit when the blockheight is larger than current blockheight', 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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits when all values are valid', async () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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'
|
||||
|
|
@ -15,7 +16,6 @@ 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'
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,9 @@ vi.mock('@/hooks/useFeatures', () => ({
|
|||
|
||||
vi.mock('@/store/jamSettingsStore', () => ({
|
||||
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
|
||||
usePreviewFeatures: () => ({
|
||||
'tx-history': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../dev/DevBadge', () => ({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
CurlyBracesIcon,
|
||||
DownloadIcon,
|
||||
HandCoinsIcon,
|
||||
HistoryIcon,
|
||||
LogsIcon,
|
||||
MilkIcon,
|
||||
NotebookTabsIcon,
|
||||
|
|
@ -40,14 +41,16 @@ 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 } from '@/store/jamSettingsStore'
|
||||
import { useDeveloperMode, usePreviewFeatures } 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(
|
||||
|
|
@ -94,8 +97,18 @@ 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],
|
||||
[t, previewFeatures],
|
||||
)
|
||||
|
||||
const settingsItems = useMemo(
|
||||
|
|
@ -180,6 +193,7 @@ 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 && (
|
||||
|
|
|
|||
|
|
@ -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,32 +143,43 @@ 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-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="font-mono break-all select-all">{fileName}</CardTitle>
|
||||
<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>
|
||||
{/* Search + action buttons */}
|
||||
<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
|
||||
<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"
|
||||
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')}
|
||||
/>
|
||||
{searchValue.length > 0 && (
|
||||
<InputGroupAddon align="inline-start">
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<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 className="h-4 w-4" />
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<Button
|
||||
className="hover:[&>svg]:motion-safe:animate-bounce"
|
||||
variant="outline"
|
||||
|
|
@ -179,15 +190,6 @@ 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 */}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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'
|
||||
|
|
@ -38,7 +39,7 @@ export function useJmwalletdStdoutLog({ enabled = true }: UseJmwalletdStdoutLogP
|
|||
refetchInterval: (query) => {
|
||||
if (!enabled || token === undefined) return false
|
||||
if (query.state.error) return false
|
||||
return 2_500
|
||||
return JAM.JMWALLETD_LOGS_POLLING_INTERVAL
|
||||
},
|
||||
// Keep previous data during refetch to prevent content flicker on slower networks.
|
||||
placeholderData: (previousData) => previousData,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, type ComponentProps } from 'react'
|
||||
import { RefreshCwIcon, WalletIcon } from 'lucide-react'
|
||||
import { LanguagesIcon, 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'
|
||||
|
|
@ -71,22 +72,36 @@ export const LoginCard = ({
|
|||
<>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="flex flex-col items-center space-y-2">
|
||||
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
{listWalletsFetching ? (
|
||||
<Spinner className="size-6" />
|
||||
) : (
|
||||
<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">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-primary h-10 w-10 rounded-full"
|
||||
title={t('global.retry')}
|
||||
aria-label={t('global.retry')}
|
||||
onClick={() => void onReloadClick()}
|
||||
aria-label={t('settings.label_select_language_aria_label')}
|
||||
title={t('settings.label_select_language_title')}
|
||||
className="self-start justify-self-end"
|
||||
>
|
||||
<WalletIcon />
|
||||
<LanguagesIcon className="size-5" />
|
||||
<span className="sr-only">{t('settings.label_select_language')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</LanguageSelectorDropdownMenu>
|
||||
</div>
|
||||
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
|
||||
{listWalletsLoading ? (
|
||||
|
|
@ -103,7 +118,7 @@ export const LoginCard = ({
|
|||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
{listWalletsError ? (
|
||||
<>
|
||||
<WalletLoadErrorAlert reason={getErrorReason(listWalletsError, t('global.errors.reason_unknown'))} />
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ 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'
|
||||
|
|
@ -36,7 +37,6 @@ 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: String((offer.fidelity_bond_value || 0).toFixed(0)),
|
||||
displayValue: Math.floor(offer.fidelity_bond_value || 0).toLocaleString(),
|
||||
locktime: fidelityBond?.locktime,
|
||||
displayLocktime: fidelityBond?.locktime ? new Date(fidelityBond.locktime * 1_000).toDateString() : undefined,
|
||||
displayExpiresIn: fidelityBond?.locktime
|
||||
|
|
|
|||
|
|
@ -116,16 +116,19 @@ 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) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant={info.getValue().badgeColor}>{info.getValue().displayValue}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{info.getValue().tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
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>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
align: 'center',
|
||||
alphabetic: true,
|
||||
|
|
@ -140,12 +143,18 @@ export const OrderbookTable = ({
|
|||
}
|
||||
return a.original.fee.value - b.original.fee.value
|
||||
},
|
||||
cell: (info) => {
|
||||
const entry = info.row.original
|
||||
return entry.fee.displayValue.includes('%') ? (
|
||||
<span className="font-mono">{entry.fee.displayValue}</span>
|
||||
cell: ({
|
||||
row: {
|
||||
original: {
|
||||
type: { isAbsolute },
|
||||
fee,
|
||||
},
|
||||
},
|
||||
}) => {
|
||||
return isAbsolute ? (
|
||||
<span className="slashed-zero tabular-nums">{fee.displayValue}</span>
|
||||
) : (
|
||||
<Balance valueString={entry.fee.displayValue} />
|
||||
<Balance valueString={fee.displayValue} />
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
|
|
@ -184,26 +193,29 @@ 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: (info) => {
|
||||
const entry = info.row.original
|
||||
return entry.bondValue.value > 0 ? (
|
||||
cell: ({
|
||||
row: {
|
||||
original: { bondValue },
|
||||
},
|
||||
}) => {
|
||||
return bondValue.value > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help">{entry.bondValue.displayValue}</span>
|
||||
<span className="cursor-help slashed-zero tabular-nums">{bondValue.displayValue}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div>
|
||||
<Balance valueString={String(entry.bondValue.amount || 0)} convertToUnit="btc" />
|
||||
{entry.bondValue.displayLocktime && (
|
||||
<Balance valueString={String(bondValue.amount || 0)} convertToUnit="btc" />
|
||||
{bondValue.displayLocktime && (
|
||||
<div className="mt-1 text-xs">
|
||||
{entry.bondValue.displayLocktime} ({entry.bondValue.displayExpiresIn})
|
||||
{bondValue.displayLocktime} ({bondValue.displayExpiresIn})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>{entry.bondValue.displayValue}</>
|
||||
<>{bondValue.displayValue}</>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/components/ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({ name, isSelected, onClick }: { name: string; isSelected: boolean; onClick: () => void }) => (
|
||||
<button type="button" aria-pressed={isSelected} onClick={onClick}>
|
||||
SelectableJar: ({ name, isSelected, onSelect }: { name: string; isSelected: boolean; onSelect: () => void }) => (
|
||||
<button type="button" aria-pressed={isSelected} onClick={onSelect}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div className="flex flex-1 flex-row flex-wrap items-start justify-around gap-8">
|
||||
{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}
|
||||
onClick={() => {
|
||||
onSelect={() => {
|
||||
setValue('source.fromJar', jar.jarIndex, { shouldValidate: true })
|
||||
}}
|
||||
disabled={disabled || isSubmitting}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => {
|
|||
|
||||
return {
|
||||
developerMode: false,
|
||||
rescanning: false,
|
||||
getAddress: vi.fn(),
|
||||
defaultJars,
|
||||
jars: defaultJars,
|
||||
|
|
@ -107,6 +108,12 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamSessionInfoContext', () => ({
|
||||
useRescanStatus: () => ({
|
||||
rescanInfo: { rescanning: mocks.rescanning },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useApiClient', () => ({
|
||||
useApiClient: () => ({}),
|
||||
}))
|
||||
|
|
@ -148,6 +155,7 @@ 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' } })
|
||||
|
|
@ -175,6 +183,17 @@ 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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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'
|
||||
|
|
@ -34,6 +35,7 @@ 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>()
|
||||
|
|
@ -67,17 +69,18 @@ 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: Number.parseInt(getAddressOptions.path.mixdepth, 10),
|
||||
sourceJarIndex,
|
||||
}
|
||||
},
|
||||
{
|
||||
delayAfter: 21,
|
||||
throttle: 210,
|
||||
},
|
||||
),
|
||||
retry: false,
|
||||
|
|
@ -107,6 +110,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
}
|
||||
|
||||
const fetchNewAddress = async () => {
|
||||
if (rescanInfo.rescanning) return
|
||||
await getAddressMutation.mutateAsync()
|
||||
}
|
||||
|
||||
|
|
@ -132,7 +136,7 @@ export const ReceivePage = ({ walletFileName }: ReceivePageProps) => {
|
|||
variant={jarButtonVariant(selectedSourceJarIndex)}
|
||||
size="lg"
|
||||
onClick={() => void fetchNewAddress()}
|
||||
disabled={getAddressMutation.isPending}
|
||||
disabled={getAddressMutation.isPending || rescanInfo.rescanning}
|
||||
>
|
||||
<HatGlassesIcon />
|
||||
{t('receive.button_reveal_address')}
|
||||
|
|
@ -189,7 +193,11 @@ 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}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void fetchNewAddress()}
|
||||
disabled={getAddressMutation.isPending || rescanInfo.rescanning}
|
||||
>
|
||||
{getAddressMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCwIcon className="animate-spin motion-reduce:hidden" />
|
||||
|
|
|
|||
135
src/components/send/ActiveCollaborativeSendAlert.test.tsx
Normal file
135
src/components/send/ActiveCollaborativeSendAlert.test.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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()
|
||||
})
|
||||
})
|
||||
144
src/components/send/ActiveCollaborativeSendAlert.tsx
Normal file
144
src/components/send/ActiveCollaborativeSendAlert.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
14
src/components/send/JarSelectorDialog.schema.test.ts
Normal file
14
src/components/send/JarSelectorDialog.schema.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
15
src/components/send/JarSelectorDialog.schema.ts
Normal file
15
src/components/send/JarSelectorDialog.schema.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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()
|
||||
|
|
@ -11,16 +11,16 @@ vi.mock('react-i18next', () => ({
|
|||
vi.mock('../ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({
|
||||
name,
|
||||
onClick,
|
||||
onSelect,
|
||||
disabled,
|
||||
isSelected,
|
||||
}: {
|
||||
name?: string
|
||||
onClick?: () => void
|
||||
onSelect?: () => void
|
||||
disabled?: boolean
|
||||
isSelected?: boolean
|
||||
}) => (
|
||||
<button onClick={onClick} disabled={disabled} data-selected={isSelected}>
|
||||
<button type="button" onClick={onSelect} disabled={disabled} data-selected={isSelected}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
@ -94,20 +94,23 @@ describe('JarSelectorDialog', () => {
|
|||
expect(screen.queryByText('Pick a jar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the confirm button until a jar is selected', () => {
|
||||
it('disables the confirm button until a jar is selected', async () => {
|
||||
renderDialog()
|
||||
const confirm = screen.getByText('modal.confirm_button_accept').closest('button')
|
||||
const confirm = screen.getByRole('button', { name: 'modal.confirm_button_accept' })
|
||||
expect(confirm).toBeDisabled()
|
||||
fireEvent.click(screen.getByText('Jar 0'))
|
||||
expect(confirm).not.toBeDisabled()
|
||||
await waitFor(() => expect(confirm).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('confirms the selected jar', async () => {
|
||||
const onConfirm = vi.fn().mockResolvedValue(undefined)
|
||||
renderDialog({ onConfirm })
|
||||
fireEvent.click(screen.getByText('Jar 0'))
|
||||
fireEvent.click(screen.getByText('modal.confirm_button_accept'))
|
||||
const confirm = screen.getByRole('button', { name: 'modal.confirm_button_accept' })
|
||||
await waitFor(() => expect(confirm).not.toBeDisabled())
|
||||
fireEvent.click(confirm)
|
||||
await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(0))
|
||||
await waitFor(() => expect(confirm).toBeDisabled())
|
||||
})
|
||||
|
||||
it('closes and resets via the reject button', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useState, type ComponentProps } from 'react'
|
||||
import { useMemo, type ComponentProps } from 'react'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Jar } from '@/context/JamWalletInfoContext'
|
||||
import type { BalanceSummary } from '@/lib/balanceSummary'
|
||||
|
|
@ -7,6 +9,7 @@ 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'>,
|
||||
|
|
@ -33,73 +36,100 @@ export default function JarSelectorDialog({
|
|||
}: JarSelectorDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isConfirming, setIsConfirming] = useState(false)
|
||||
const [selectedJar, setSelectedJar] = useState<Jar>()
|
||||
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 handleClose = () => {
|
||||
setSelectedJar(undefined)
|
||||
reset()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
if (selectedJar === undefined) return
|
||||
|
||||
setIsConfirming(true)
|
||||
try {
|
||||
await onConfirm(selectedJar.jarIndex)
|
||||
setSelectedJar(undefined)
|
||||
} finally {
|
||||
setIsConfirming(false)
|
||||
}
|
||||
}
|
||||
const doOnSubmit = handleSubmit(async ({ jarIndex }) => {
|
||||
await onConfirm(jarIndex)
|
||||
reset()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose} {...dialogProps}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">{title}</DialogTitle>
|
||||
{subtitle && <DialogDescription>{subtitle}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: ({ value }: { value: string }) => <span>address:{value}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString: string }) => <span>balance:{valueString}</span>,
|
||||
}))
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ const sourceJar: Jar = {
|
|||
calculatedAvailableBalanceInSats: 50_000,
|
||||
calculatedTotalBalanceInSats: 50_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 50_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#e2b86a',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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'
|
||||
|
|
@ -13,7 +14,6 @@ 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'
|
||||
|
|
|
|||
|
|
@ -1,84 +1,10 @@
|
|||
import { AlertTriangleIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
|
||||
import { formatSats, shortenStringMiddle } from '@/lib/utils'
|
||||
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
|
||||
|
||||
interface SendCoinjoinPreconditionAlertProps {
|
||||
summary: SweepPreconditionSummary
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <PreconditionAlert summary={summary} i18nPrefix="send.coinjoin_precondition" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
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,
|
||||
|
|
@ -10,32 +13,58 @@ import {
|
|||
toSendFormDefaultValues,
|
||||
} from './SendForm.schema'
|
||||
|
||||
const t = vi.fn((key: string) => key) as unknown as TFunction<'translation', undefined>
|
||||
const t = vi.fn((key: string, options?: Record<string, unknown>) =>
|
||||
options ? `${key}:${JSON.stringify(options)}` : key,
|
||||
) as unknown as TFunction<'translation', undefined>
|
||||
|
||||
const validMainnetAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
|
||||
const sourceJarAddress = '1CounterpartyXXXXXXXXXXXXXXXUWLpVr'
|
||||
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 jars = [
|
||||
{
|
||||
jarIndex: 0,
|
||||
balanceSummary: {
|
||||
calculatedAvailableBalanceInSats: 10_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
jarIndex: 1,
|
||||
balanceSummary: {
|
||||
calculatedAvailableBalanceInSats: 0,
|
||||
},
|
||||
},
|
||||
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 }),
|
||||
]),
|
||||
] as Jar[]
|
||||
|
||||
const addressSummary = {
|
||||
[validMainnetAddress]: {
|
||||
address: validMainnetAddress,
|
||||
jarIndex: 2,
|
||||
used: false,
|
||||
},
|
||||
[sourceJarAddress]: {
|
||||
address: sourceJarAddress,
|
||||
jarIndex: 0,
|
||||
|
|
@ -81,22 +110,48 @@ describe('toSendFormDefaultValues', () => {
|
|||
})
|
||||
|
||||
describe('createSendFormSchema', () => {
|
||||
const schema = createSendFormSchema(jars, addressSummary, 3, Network.mainnet, t)
|
||||
const schema = createSendFormSchema(
|
||||
jars,
|
||||
addressSummary,
|
||||
JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS,
|
||||
Network.mainnet,
|
||||
t,
|
||||
)
|
||||
|
||||
it('accepts a valid coinjoin send', async () => {
|
||||
await expect(schema.validate(validFormValues)).resolves.toMatchObject(validFormValues)
|
||||
})
|
||||
|
||||
it('rejects unavailable source jars', async () => {
|
||||
it('rejects source jars with zero balance', async () => {
|
||||
await expect(
|
||||
schema.validate({
|
||||
...validFormValues,
|
||||
source: { fromJar: 1 },
|
||||
source: { fromJar: jars[1].jarIndex },
|
||||
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({
|
||||
|
|
@ -131,13 +186,30 @@ 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: 11_000 },
|
||||
amount: {
|
||||
isSweep: false,
|
||||
amount: jars[validFormValues.source.fromJar].balanceSummary.calculatedTotalBalanceInSats + 1,
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow('send.feedback_amount_exceeds_balance')
|
||||
|
||||
|
|
@ -145,7 +217,7 @@ describe('createSendFormSchema', () => {
|
|||
schema.validate({
|
||||
...validFormValues,
|
||||
isCoinJoin: false,
|
||||
numCollaborators: 4,
|
||||
numCollaborators: 10,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
isCoinJoin: false,
|
||||
|
|
@ -153,6 +225,29 @@ 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({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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'
|
||||
|
|
@ -21,6 +22,8 @@ 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,
|
||||
|
|
@ -64,11 +67,28 @@ export const createSendFormSchema = (
|
|||
.object({
|
||||
source: yup
|
||||
.object({
|
||||
fromJar: sourceJarField(
|
||||
t('send.feedback_invalid_source_jar'),
|
||||
(jarIndex) =>
|
||||
(jars.find((it) => it.jarIndex === jarIndex)?.balanceSummary.calculatedAvailableBalanceInSats || 0) > 0,
|
||||
),
|
||||
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
|
||||
},
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
destination: yup
|
||||
|
|
@ -137,7 +157,26 @@ export const createSendFormSchema = (
|
|||
// eslint-disable-next-line unicorn/prefer-spread -- false positive
|
||||
.concat(createTxFeeFormSchema({ t }))
|
||||
.required()
|
||||
.test('address-not-from-source-jar-test', function (root) {
|
||||
.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) => {
|
||||
// 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
|
||||
|
|
@ -145,9 +184,15 @@ export const createSendFormSchema = (
|
|||
|
||||
const errorMessage = t('send.feedback_address_from_source_jar')
|
||||
|
||||
return new yup.ValidationError(errorMessage, root.destination.address, 'destination.address', undefined, true)
|
||||
return new yup.ValidationError(
|
||||
errorMessage,
|
||||
root.destination.address,
|
||||
'destination.address',
|
||||
'address-not-from-source-jar-test',
|
||||
true,
|
||||
)
|
||||
})
|
||||
.test('amount-exceeds-balance-test', function (root) {
|
||||
.test((root) => {
|
||||
if (root.amount.isSweep) return true
|
||||
if (root.amount.amount === undefined || root.amount.amount === null) return true
|
||||
|
||||
|
|
@ -161,7 +206,7 @@ export const createSendFormSchema = (
|
|||
t('send.feedback_amount_exceeds_balance'),
|
||||
root.amount.amount,
|
||||
'amount.amount',
|
||||
undefined,
|
||||
'amount-exceeds-balance-test',
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -18,9 +18,22 @@ 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) => {
|
||||
|
|
@ -33,11 +46,21 @@ 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' }),
|
||||
}))
|
||||
|
|
@ -55,13 +78,13 @@ vi.mock('../ui/jam/Address', () => ({
|
|||
Address: () => <div data-testid="address" />,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/Balance', () => ({
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <div data-testid="balance">{valueString}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../ui/jam/SelectableJar', () => ({
|
||||
SelectableJar: ({ name, onClick, disabled }: { name?: string; onClick?: () => void; disabled?: boolean }) => (
|
||||
<button onClick={onClick} disabled={disabled}>
|
||||
SelectableJar: ({ name, onSelect, disabled }: { name?: string; onSelect?: () => void; disabled?: boolean }) => (
|
||||
<button onClick={onSelect} disabled={disabled}>
|
||||
{name}
|
||||
</button>
|
||||
),
|
||||
|
|
@ -156,6 +179,9 @@ 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', () => {
|
||||
|
|
@ -286,7 +312,7 @@ describe('SendForm', () => {
|
|||
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('global.errors.error_loading_address_failed'))
|
||||
})
|
||||
|
||||
it('applies a pasted bip21 uri', async () => {
|
||||
it('applies a pasted bip21 uri and shows success toast', async () => {
|
||||
renderForm()
|
||||
|
||||
const inputBefore = document.querySelector('#send-destination') as HTMLInputElement
|
||||
|
|
@ -303,7 +329,7 @@ describe('SendForm', () => {
|
|||
await flushActUpdates()
|
||||
})
|
||||
|
||||
it('ignores a non-bitcoin paste', () => {
|
||||
it('ignores a non-bitcoin-uri paste', () => {
|
||||
renderForm()
|
||||
const input = document.querySelector('#send-destination') as HTMLInputElement
|
||||
|
||||
|
|
@ -312,6 +338,18 @@ 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()
|
||||
|
|
@ -356,4 +394,49 @@ 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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,15 +5,16 @@ 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 { Resolver, SubmitHandler } from 'react-hook-form'
|
||||
import type { FieldErrors, Resolver, SubmitHandler } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import QrScannerDialog from '@/components/ui/QrScannerDialog'
|
||||
import { isDevMode } from '@/constants/debugFeatures'
|
||||
import { Balance } from '@/components/ui/jam/Balance'
|
||||
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'
|
||||
|
|
@ -31,8 +32,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'
|
||||
|
|
@ -94,12 +95,6 @@ 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'}
|
||||
|
|
@ -114,7 +109,8 @@ interface SendFormProps {
|
|||
className?: string
|
||||
onSubmit: SubmitHandler<SendFormValues>
|
||||
onSourceJarChange?: (jarIndex: JarIndex | undefined) => void
|
||||
sourceJarLabelButton?: React.ReactElement
|
||||
onSourceJarClicked?: (jarIndex: JarIndex | undefined) => void
|
||||
sourceJarLabelButton?: (errors?: FieldErrors<{ fromJar: number }>) => React.ReactElement
|
||||
minNumberOfCollaborators?: number
|
||||
feeConfigValues: JamFeeConfigValues
|
||||
walletFileName: WalletFileName
|
||||
|
|
@ -129,11 +125,12 @@ export function SendForm({
|
|||
className,
|
||||
onSubmit,
|
||||
onSourceJarChange,
|
||||
onSourceJarClicked,
|
||||
sourceJarLabelButton,
|
||||
disabled,
|
||||
feeConfigValues,
|
||||
walletFileName,
|
||||
minNumberOfCollaborators = MIN_NUM_COLLABORATORS,
|
||||
minNumberOfCollaborators = JM_MINIMUM_MAKERS_DEFAULT,
|
||||
jars,
|
||||
walletBalanceSummary,
|
||||
addressSummary,
|
||||
|
|
@ -187,6 +184,16 @@ 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
|
||||
|
|
@ -204,6 +211,15 @@ 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)
|
||||
|
|
@ -259,7 +275,9 @@ export function SendForm({
|
|||
|
||||
event.preventDefault()
|
||||
applyBip21Result(parsed)
|
||||
toast.success(t('send.qr_scan_bip21_applied'))
|
||||
if (parsed.fromUri) {
|
||||
toast.success(t('send.qr_scan_bip21_applied'))
|
||||
}
|
||||
},
|
||||
[applyBip21Result, t],
|
||||
)
|
||||
|
|
@ -298,47 +316,67 @@ export function SendForm({
|
|||
{...sendFormMethods}
|
||||
>
|
||||
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
|
||||
<div className="space-y-2">
|
||||
<Field className="space-y-4" data-invalid={errors.source !== undefined}>
|
||||
<div className="space-y-4">
|
||||
<Field className="space-y-4" data-invalid={errors.source?.fromJar !== undefined}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FieldLabel>{t('send.label_source_jar')}</FieldLabel>
|
||||
{sourceJarLabelButton && <>{sourceJarLabelButton}</>}
|
||||
{sourceJarLabelButton?.(errors.source)}
|
||||
</div>
|
||||
<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 })
|
||||
<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
|
||||
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({
|
|||
stopCoinjoinRefetch: vi.fn(),
|
||||
takerRunning: false,
|
||||
schedulerRunning: false,
|
||||
makerRunning: false,
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
|
|
@ -33,6 +34,9 @@ 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', () => ({
|
||||
|
|
@ -72,11 +76,23 @@ 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,
|
||||
|
|
@ -145,6 +161,10 @@ vi.mock('@/store/jamSettingsStore', () => ({
|
|||
useDeveloperMode: () => ({ enabled: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamDisplayContext', () => ({
|
||||
useJamDisplayContext: () => ({ addressChunkingEnabled: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/JamSessionInfoContext', () => ({
|
||||
useJamSessionInfoContext: () => ({
|
||||
clearCurrentPaymentAttempt: mocks.clearCurrentPaymentAttempt,
|
||||
|
|
@ -157,6 +177,9 @@ vi.mock('@/context/JamSessionInfoContext', () => ({
|
|||
running: mocks.schedulerRunning,
|
||||
},
|
||||
},
|
||||
makerInfo: {
|
||||
running: mocks.makerRunning,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
@ -209,11 +232,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)
|
||||
|
|
@ -241,6 +264,7 @@ vi.mock('./UtxoSelectionDialog', () => ({
|
|||
const balanceSummary = {
|
||||
calculatedAvailableBalanceInSats: 20_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 20_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 20_000,
|
||||
}
|
||||
|
|
@ -322,6 +346,9 @@ 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,
|
||||
|
|
@ -509,15 +536,7 @@ describe('SendPage', () => {
|
|||
})
|
||||
|
||||
it('shows the maker running warning', async () => {
|
||||
jmSessionStore.setState({
|
||||
state: {
|
||||
coinjoin_in_process: false,
|
||||
maker_running: true,
|
||||
session: true,
|
||||
wallet_name: 'wallet.jmdat',
|
||||
rescanning: false,
|
||||
},
|
||||
})
|
||||
mocks.makerRunning = true
|
||||
|
||||
render(<SendPage walletFileName="wallet.jmdat" />)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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,
|
||||
|
|
@ -34,8 +35,9 @@ 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 { scrollToTop, type WalletFileName } from '@/lib/utils'
|
||||
import { cn, scrollToTop, type WalletFileName } from '@/lib/utils'
|
||||
import { useDeveloperMode } from '@/store/jamSettingsStore'
|
||||
import { jmSessionStore } from '@/store/jmSessionStore'
|
||||
import { jmTxStore, type JmTxInfo } from '@/store/jmTxStore'
|
||||
|
|
@ -43,9 +45,11 @@ 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'
|
||||
|
|
@ -81,8 +85,9 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
waitForUtxosToBeSpent,
|
||||
setWaitForUtxosToBeSpent,
|
||||
} = useJamWalletInfoContext()
|
||||
const jmSession = useStore(jmSessionStore, (state) => state.state)
|
||||
const jmSessionActive = useStore(jmSessionStore, (state) => state.state?.session)
|
||||
const {
|
||||
makerInfo: { running: makerRunning },
|
||||
takerInfo: {
|
||||
running: takerRunning,
|
||||
currentPaymentAttempt,
|
||||
|
|
@ -114,15 +119,20 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
return jars.find((it) => it.jarIndex === sourceJarIndex)
|
||||
}, [jars, sourceJarIndex])
|
||||
|
||||
const utxoSelectionDialog = useUtxoSelectionDialog({
|
||||
const { onOpenUtxoSelector, ...utxoSelectionDialog } = useUtxoSelectionDialog({
|
||||
walletFileName,
|
||||
sourceJar,
|
||||
addressSummary,
|
||||
})
|
||||
|
||||
const availableUtxosForPayment = useMemo(() => {
|
||||
return (sourceJar?.utxos || []).filter((utxo) => !utxo.frozen).toSorted((a, b) => a.confirmations - b.confirmations)
|
||||
}, [sourceJar])
|
||||
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])
|
||||
|
||||
const destinationJar = useMemo(() => {
|
||||
const destinationJarIndex = sendFromValuesAwaitingConfirmation?.destination?.fromJar
|
||||
|
|
@ -197,14 +207,14 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
|
||||
useRefreshSession({
|
||||
enabled: isWaitingCoinjoinStart || isWaitingCoinjoinStop,
|
||||
refetchInterval: 3_000,
|
||||
refetchDelay: 1_000,
|
||||
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
})
|
||||
|
||||
useRefreshSession({
|
||||
enabled: takerRunning,
|
||||
refetchInterval: 5_000,
|
||||
refetchDelay: 1_000,
|
||||
refetchInterval: JAM.RUNNING_COINJOIN_POLLING_INTERVAL,
|
||||
refetchDelay: JAM.RUNNING_COINJOIN_POLLING_DELAY,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -361,7 +371,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
await stopCoinjoinMutationMutateAsync()
|
||||
}
|
||||
|
||||
if (!jmSession || walletInfoIsLoading) {
|
||||
if (!jmSessionActive || walletInfoIsLoading) {
|
||||
return <PageLoading />
|
||||
}
|
||||
|
||||
|
|
@ -403,7 +413,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
{feeConfigValidation.maxFeesConfigMissing && (
|
||||
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
|
||||
)}
|
||||
{jmSession?.maker_running === true && (
|
||||
{makerRunning === true && (
|
||||
<Alert variant="warning">
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
|
||||
|
|
@ -439,102 +449,77 @@ 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 !== 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>
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{takerRunning && !isWaitingCoinjoinStop && (
|
||||
|
||||
{schedulerRunning && (
|
||||
<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 />
|
||||
|
|
@ -579,7 +564,11 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
</>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Card
|
||||
className={cn('transition-all duration-500', {
|
||||
'blur-[2px]': currentPaymentAttempt !== undefined || takerRunning || schedulerRunning || makerRunning,
|
||||
})}
|
||||
>
|
||||
<CardContent>
|
||||
<SendForm
|
||||
key={formId}
|
||||
|
|
@ -591,7 +580,7 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
addressSummary={addressSummary}
|
||||
walletBalanceSummary={walletBalanceSummary}
|
||||
disabled={
|
||||
jmSession?.maker_running === true ||
|
||||
makerRunning ||
|
||||
takerRunning ||
|
||||
rescanInfo.rescanning ||
|
||||
isWaitingCoinjoinStart ||
|
||||
|
|
@ -604,17 +593,23 @@ export const SendPage = ({ walletFileName }: SendPageProps) => {
|
|||
}
|
||||
debug={isDeveloperMode}
|
||||
onSourceJarChange={setSourceJarIndex}
|
||||
sourceJarLabelButton={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={utxoSelectionDialog.utxoSelectorDisabled}
|
||||
onClick={utxoSelectionDialog.onOpenUtxoSelector}
|
||||
>
|
||||
<ListFilterIcon />
|
||||
{t('show_utxos.text_select_utxos_tooltip')}
|
||||
</Button>
|
||||
}
|
||||
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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
22
src/components/send/UtxoSelectionDialog.schema.test.ts
Normal file
22
src/components/send/UtxoSelectionDialog.schema.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
32
src/components/send/UtxoSelectionDialog.schema.ts
Normal file
32
src/components/send/UtxoSelectionDialog.schema.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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()
|
||||
}
|
||||
|
|
@ -37,40 +37,49 @@ export const UtxoSelectionDialog = ({
|
|||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('show_utxos.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
|
||||
<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}
|
||||
<Input
|
||||
className="shrink-0"
|
||||
value={filter}
|
||||
onChange={(event) => onFilterChange(event.target.value)}
|
||||
placeholder={t('jar_details.utxo_list.placeholder_search')}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ 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'
|
||||
|
|
@ -173,9 +174,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">
|
||||
{account.accountName}
|
||||
{accountLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 font-mono">{accountLabel}</span>
|
||||
<JarBadge jarIndex={account.accountIndex} name={account.accountName} />
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-1 p-4 pt-2">
|
||||
<Label className="text-muted-foreground text-sm">
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ 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'
|
||||
|
||||
|
|
@ -85,18 +86,20 @@ describe('RescanChainPage', () => {
|
|||
})
|
||||
|
||||
it('renders the form', async () => {
|
||||
await renderPage()
|
||||
await withRuntimeLocale('en-US', async () => {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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'
|
||||
|
|
@ -17,7 +16,7 @@ import {
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { useApiClient } from '@/hooks/useApiClient'
|
||||
import { getErrorReason } from '@/lib/errorReason'
|
||||
import type { WalletFileName } from '@/lib/utils'
|
||||
import { cn, type WalletFileName } from '@/lib/utils'
|
||||
import type { Milliseconds, WithRequiredProperty } from '@/types/global'
|
||||
import { SeedPhraseGrid } from '../ui/jam/SeedPhraseGrid'
|
||||
import { Spinner } from '../ui/spinner'
|
||||
|
|
@ -33,7 +32,6 @@ type SeedPhraseDialogProps = WithRequiredProperty<
|
|||
autoCloseTimeout: Milliseconds
|
||||
}
|
||||
|
||||
// TODO: use react-hook-form and yup schema
|
||||
export const SeedPhraseDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -190,13 +188,13 @@ export const SeedPhraseDialog = ({
|
|||
<DialogFooter>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div
|
||||
className={cx('text-muted-foreground flex items-center gap-1 text-sm', {
|
||||
className={cn('text-muted-foreground flex items-center gap-1 text-sm', {
|
||||
'text-destructive! animate-pulse': secondsLeft <= 10,
|
||||
})}
|
||||
>
|
||||
<ClockIcon className="h-4 w-4" />
|
||||
<span
|
||||
className={cx('mt-0.5 font-mono', {
|
||||
className={cn('mt-0.5 font-mono', {
|
||||
hidden: secondsLeft < 1,
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
183
src/components/settings/SettingsItem.test.tsx
Normal file
183
src/components/settings/SettingsItem.test.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,24 +11,37 @@ 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 SettingItem = ({
|
||||
export const SettingsItem = ({
|
||||
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" />}
|
||||
|
|
@ -39,13 +52,34 @@ export const SettingItem = ({
|
|||
</div>
|
||||
</div>
|
||||
{children !== undefined && <div className="shrink-0">{children}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return content
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsLinkProps = Omit<SettingsItemProps, 'action' | 'children'> & {
|
||||
type SettingsLinkProps = Omit<SettingsItemProps, 'action' | 'children' | 'hasInteractiveChild'> & {
|
||||
to: string
|
||||
external?: boolean
|
||||
}
|
||||
|
|
@ -54,7 +88,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
|
|||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<SettingItem
|
||||
<SettingsItem
|
||||
{...props}
|
||||
action={async () => {
|
||||
if (external) {
|
||||
|
|
@ -65,19 +99,22 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
|
|||
}}
|
||||
>
|
||||
{external && <ExternalLinkIcon className="text-muted-foreground size-3.5" />}
|
||||
</SettingItem>
|
||||
</SettingsItem>
|
||||
)
|
||||
}
|
||||
|
||||
type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action'> & {
|
||||
type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action' | 'hasInteractiveChild'> & {
|
||||
checked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
displayToggle?: boolean
|
||||
}
|
||||
export const SettingSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
|
||||
export const SettingsSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => {
|
||||
return (
|
||||
<SettingItem {...props} action={() => onCheckedChange?.(!checked)}>
|
||||
// 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}>
|
||||
{displayToggle && <Switch checked={checked} onCheckedChange={onCheckedChange} disabled={props.disabled} />}
|
||||
</SettingItem>
|
||||
</SettingsItem>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,9 @@ vi.mock('next-themes', () => ({
|
|||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: {
|
||||
resolvedLanguage: 'en',
|
||||
},
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
|
@ -123,7 +126,7 @@ vi.mock('@/components/settings/AccountXpubsDialog', () => ({
|
|||
) : null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/settings/LanguageSelector', () => ({
|
||||
vi.mock('@/components/ui/jam/LanguageSelector', () => ({
|
||||
LanguageSelector: () => <div>language-selector</div>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import {
|
|||
UnfoldHorizontalIcon,
|
||||
KeyRoundIcon,
|
||||
HandCoinsIcon,
|
||||
SparklesIcon,
|
||||
HistoryIcon,
|
||||
LanguagesIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -23,8 +26,10 @@ 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'
|
||||
|
|
@ -34,11 +39,9 @@ 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 { SettingItem, SettingsLink, SettingSwitch } from './SettingsItem'
|
||||
import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem'
|
||||
import { FeeConfigDialog } from './fees/FeeConfigDialog'
|
||||
|
||||
interface SettingPageProps {
|
||||
|
|
@ -79,7 +82,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
<CardTitle>{t('settings.section_title_display')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SettingSwitch
|
||||
<SettingsSwitch
|
||||
icon={isPrivate ? EyeIcon : EyeOffIcon}
|
||||
title={t(isPrivate ? 'settings.show_balance' : 'settings.hide_balance')}
|
||||
checked={isPrivate}
|
||||
|
|
@ -87,7 +90,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
displayToggle={false}
|
||||
/>
|
||||
<Separator className="opacity-50" />
|
||||
<SettingSwitch
|
||||
<SettingsSwitch
|
||||
renderIcon={({ className }) => <CurrencySymbol currency={currency} className={className} />}
|
||||
title={t(currency === 'btc' ? 'settings.use_btc' : 'settings.use_sats')}
|
||||
checked={currency === 'btc'}
|
||||
|
|
@ -95,7 +98,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
displayToggle={false}
|
||||
/>
|
||||
<Separator className="opacity-50" />
|
||||
<SettingSwitch
|
||||
<SettingsSwitch
|
||||
icon={addressChunkingEnabled === true ? UnfoldHorizontalIcon : FoldHorizontalIcon}
|
||||
title={t(
|
||||
addressChunkingEnabled === true
|
||||
|
|
@ -107,7 +110,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
displayToggle={true}
|
||||
/>
|
||||
<Separator className="opacity-50" />
|
||||
<SettingSwitch
|
||||
<SettingsSwitch
|
||||
icon={resolvedTheme === 'dark' ? SunIcon : MoonIcon}
|
||||
title={resolvedTheme === 'dark' ? t('settings.use_light_theme') : t('settings.use_dark_theme')}
|
||||
checked={resolvedTheme === 'dark'}
|
||||
|
|
@ -115,7 +118,9 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
displayToggle={false}
|
||||
/>
|
||||
<Separator className="opacity-50" />
|
||||
<LanguageSelector />
|
||||
<SettingsItem icon={LanguagesIcon} title={t('settings.label_select_language')}>
|
||||
<LanguageSelector />
|
||||
</SettingsItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -125,7 +130,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
<CardTitle>{t('settings.section_title_market')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SettingItem
|
||||
<SettingsItem
|
||||
icon={HandCoinsIcon}
|
||||
title={t('settings.show_fee_config')}
|
||||
action={() => setShowFeeConfigDialog(true)}
|
||||
|
|
@ -139,21 +144,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
|
|||
<CardTitle>{t('settings.section_title_wallet')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0">
|
||||
<SettingItem
|
||||
<SettingsItem
|
||||
icon={KeyRoundIcon}
|
||||
title={t('settings.show_seed')}
|
||||
action={() => setShowSeedDialog(true)}
|
||||
disabled={hashedPassword === undefined}
|
||||
/>
|
||||
<Separator className="opacity-50" />
|
||||
<SettingItem
|
||||
<SettingsItem
|
||||
icon={BookKeyIcon}
|
||||
title={t('settings.show_xpubs')}
|
||||
action={() => setShowXpubsDialog(true)}
|
||||
disabled={hashedPassword === undefined}
|
||||
/>
|
||||
<Separator className="opacity-50" />
|
||||
<SettingItem
|
||||
<SettingsItem
|
||||
renderIcon={({ className }) =>
|
||||
lockWalletMutation.isPending ? (
|
||||
<Spinner className={className} />
|
||||
|
|
@ -214,10 +219,39 @@ 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" />
|
||||
<SettingSwitch
|
||||
<SettingsSwitch
|
||||
icon={TerminalIcon}
|
||||
title="Enable developer mode"
|
||||
disabled={!isDevMode()}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,18 @@ import { SweepPreconditionAlert } from './SweepPreconditionAlert'
|
|||
import type { SweepPreconditionSummary } from './preconditions'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||
Trans: ({ i18nKey, values }: { i18nKey: string; values?: unknown }) =>
|
||||
values ? `${i18nKey}:${JSON.stringify(values)}` : i18nKey,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string, options?: Record<string, unknown>) => (options ? `${key}:${JSON.stringify(options)}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
const retryLockedUtxo = {
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}sats</span>,
|
||||
}))
|
||||
|
||||
const retryLockedUtxo: Utxo = {
|
||||
address: 'bc1qretry',
|
||||
confirmations: 12,
|
||||
frozen: false,
|
||||
|
|
@ -20,18 +25,22 @@ const retryLockedUtxo = {
|
|||
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,
|
||||
|
|
@ -57,8 +66,8 @@ describe('precondition alerts', () => {
|
|||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.precondition.hint_missing_utxos')).toBeInTheDocument()
|
||||
expect(screen.getByText('send.coinjoin_precondition.hint_missing_utxos:{"minConfirmations":5}')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.precondition.hint_missing_utxos:{"minConfirmations":5}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows missing confirmation warnings for send and sweep flows', () => {
|
||||
|
|
@ -71,8 +80,30 @@ describe('precondition alerts', () => {
|
|||
</>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('send.coinjoin_precondition.hint_missing_confirmations')).toBeInTheDocument()
|
||||
expect(screen.getByText('scheduler.precondition.hint_missing_confirmations')).toBeInTheDocument()
|
||||
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()
|
||||
})
|
||||
|
||||
it('lists retry-locked utxos when retries are the remaining blocker', () => {
|
||||
|
|
@ -88,7 +119,6 @@ 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('jar 0')).toHaveLength(2)
|
||||
expect(screen.getAllByText('123,456 sats')).toHaveLength(2)
|
||||
expect(screen.getAllByText('123456sats')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { FieldArrayWithId, UseFormReturn } from 'react-hook-form'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, 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: {
|
||||
|
|
@ -30,6 +46,11 @@ 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
|
||||
|
|
@ -118,6 +139,7 @@ describe('SweepDestinationInputs', () => {
|
|||
)
|
||||
|
||||
const inputs = screen.getAllByRole('textbox')
|
||||
expect(inputs).toHaveLength(fields.length)
|
||||
await act(async () => {
|
||||
await userEvent.click(inputs[0])
|
||||
await userEvent.paste('anything')
|
||||
|
|
@ -128,19 +150,33 @@ 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', () => {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ export const SweepDestinationInputs = ({
|
|||
|
||||
event.preventDefault()
|
||||
applyBip21Result(parsed, index)
|
||||
toast.success(t('send.qr_scan_bip21_applied'))
|
||||
if (parsed.fromUri) {
|
||||
toast.success(t('send.qr_scan_bip21_applied'))
|
||||
}
|
||||
},
|
||||
[applyBip21Result, t],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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'
|
||||
|
|
@ -53,6 +54,7 @@ interface SweepFormProps {
|
|||
initialValues?: Partial<SweepFormValues>
|
||||
jars: Jar[]
|
||||
addressSummary: AddressSummary
|
||||
network: Network
|
||||
disabled?: boolean
|
||||
debug?: boolean
|
||||
}
|
||||
|
|
@ -62,6 +64,7 @@ export const SweepForm = ({
|
|||
onSubmit,
|
||||
jars,
|
||||
addressSummary,
|
||||
network,
|
||||
initialValues,
|
||||
disabled,
|
||||
debug,
|
||||
|
|
@ -82,10 +85,11 @@ export const SweepForm = ({
|
|||
minNumberOfDestinations,
|
||||
maxNumberOfDestinations,
|
||||
addressSummary,
|
||||
network,
|
||||
},
|
||||
t,
|
||||
),
|
||||
[minNumberOfDestinations, maxNumberOfDestinations, addressSummary, t],
|
||||
[minNumberOfDestinations, maxNumberOfDestinations, addressSummary, network, t],
|
||||
)
|
||||
const { formState, reset, register, control, setValue, handleSubmit, trigger } = useForm<
|
||||
SweepFormValues,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Network } from 'bitcoin-address-validation'
|
||||
import type { TFunction } from 'i18next'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
|
|
@ -12,13 +13,19 @@ 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) => {
|
||||
const validate = async (
|
||||
values: SweepFormValues,
|
||||
addressSummary = {} as AddressSummary,
|
||||
network: Network = Network.regtest,
|
||||
) => {
|
||||
return await sweepFormSchema(
|
||||
{
|
||||
minNumberOfDestinations: 1,
|
||||
maxNumberOfDestinations: 5,
|
||||
addressSummary,
|
||||
network,
|
||||
},
|
||||
t,
|
||||
).validate(values, { abortEarly: false })
|
||||
|
|
@ -49,6 +56,22 @@ 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({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Network } from 'bitcoin-address-validation'
|
||||
import type { TFunction } from 'i18next'
|
||||
import * as yup from 'yup'
|
||||
import {
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
} from '@/constants/jam'
|
||||
import { JM_NG_DEFAULT_TUMBLER_PARAMS, type TumblerParameters } from '@/constants/jm'
|
||||
import type { AddressSummary } from '@/context/JamWalletInfoContext'
|
||||
import { isValidAddress } from '@/lib/formValidation'
|
||||
import { isAddressOnNetwork, isValidAddress } from '@/lib/formValidation'
|
||||
import { factorToPercentage, isValidNumber, percentageToFactor, pseudoRandomInteger } from '@/lib/utils'
|
||||
import type { Seconds } from '@/types/global'
|
||||
import { buildDestinationErrors, normalizeDestinationAddresses } from './destinationValidation'
|
||||
|
|
@ -115,14 +116,17 @@ 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.',
|
||||
|
|
@ -137,20 +141,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, function (value) {
|
||||
if (!isValidAddress(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}),
|
||||
.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),
|
||||
),
|
||||
})
|
||||
.required(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,19 @@ 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', () => ({
|
||||
|
|
@ -264,6 +277,7 @@ vi.mock('@/components/ui/jam/PageLoading', () => ({
|
|||
|
||||
vi.mock('@/context/JamWalletInfoContext', () => ({
|
||||
useJamWalletInfoContext: () => mocks.walletInfo,
|
||||
useDetectNetwork: () => ({ network: 'regtest' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useApiClient', () => ({
|
||||
|
|
@ -302,6 +316,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
calculatedAvailableBalanceInSats: 100_000,
|
||||
calculatedTotalBalanceInSats: 100_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 100_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#e2b86a',
|
||||
|
|
@ -314,6 +329,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
calculatedAvailableBalanceInSats: 0,
|
||||
calculatedTotalBalanceInSats: 0,
|
||||
calculatedConfirmedAvailableBalanceInSats: 0,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
color: '#ccc',
|
||||
|
|
@ -328,6 +344,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
detectedNetwork: null,
|
||||
error: null,
|
||||
fidelityBondSummary: { fbOutputs: [] },
|
||||
hasEligibleFidelityBondUtxo: false,
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
jars: [jar, emptyJar(1), emptyJar(2)],
|
||||
|
|
@ -340,6 +357,7 @@ const makeWalletInfo = (overrides: Partial<WalletInfo> = {}): WalletInfo => {
|
|||
calculatedAvailableBalanceInSats: 100_000,
|
||||
calculatedTotalBalanceInSats: 100_000,
|
||||
calculatedConfirmedAvailableBalanceInSats: 100_000,
|
||||
calculatedAvailableFrozenBalanceInSats: 0,
|
||||
calculatedFrozenOrLockedBalanceInSats: 0,
|
||||
},
|
||||
walletName: 'wallet.jmdat',
|
||||
|
|
@ -399,6 +417,9 @@ 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()
|
||||
|
|
@ -717,4 +738,27 @@ 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,20 +23,21 @@ 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 { isDevMode } from '@/constants/debugFeatures'
|
||||
import * as JAM from '@/constants/jam'
|
||||
import type { TumblerParameters } from '@/constants/jm'
|
||||
import { useJamSessionInfoContext } from '@/context/JamSessionInfoContext'
|
||||
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
|
||||
import { useDetectNetwork, 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 { scrollToTop, type WalletFileName } from '@/lib/utils'
|
||||
import { cn, 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'
|
||||
|
|
@ -46,11 +47,6 @@ 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,
|
||||
|
|
@ -62,14 +58,31 @@ const INSECURE_SCHEDULE_TUMBLER_OPTIONS: Partial<TumblerParameters> = {
|
|||
export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
||||
const { t } = useTranslation()
|
||||
const client = useApiClient()
|
||||
const { rescanInfo, takerInfo, makerInfo } = useJamSessionInfoContext()
|
||||
const {
|
||||
rescanInfo,
|
||||
makerInfo: { running: makerRunning },
|
||||
takerInfo: {
|
||||
running: takerRunning,
|
||||
scheduler: { running: schedulerRunning },
|
||||
},
|
||||
} = 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 })
|
||||
|
||||
|
|
@ -84,9 +97,8 @@ 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 ? RUNNING_SCHEDULE_POLLING_INTERVAL : false
|
||||
return schedulerRunning || currentScheduleStillActive ? JAM.RUNNING_SCHEDULE_POLLING_INTERVAL : false
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
retry: false,
|
||||
|
|
@ -194,21 +206,18 @@ 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 = takerInfo.running && getScheduleQuery.isPending
|
||||
const waitingForTumblerStatus = takerRunning && getScheduleQuery.isPending
|
||||
const singleCoinJoinRunning =
|
||||
takerInfo.running && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
|
||||
const makerRunning = makerInfo.running === true
|
||||
const collaborativeOperationRunning = makerRunning || takerInfo.running
|
||||
takerRunning && !schedulerRunning && !isWaitingSchedulerStart && !waitingForTumblerStatus
|
||||
|
||||
const isWaitingSchedulerStop = stopScheduleMutationIsPending || (stopScheduleMutationIsSuccess && schedulerRunning)
|
||||
|
||||
useRefreshSession({
|
||||
enabled: isWaitingSchedulerStart || isWaitingSchedulerStop,
|
||||
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
|
||||
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -225,8 +234,10 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
|
||||
const isOperationDisabled =
|
||||
feeConfigValidation.maxFeesConfigMissing ||
|
||||
collaborativeOperationRunning ||
|
||||
makerRunning ||
|
||||
takerRunning ||
|
||||
rescanInfo.rescanning ||
|
||||
makerRunning ||
|
||||
!preconditionSummary.isFulfilled
|
||||
|
||||
const isStartDisabled =
|
||||
|
|
@ -284,6 +295,10 @@ 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>
|
||||
|
|
@ -294,14 +309,14 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
{singleCoinJoinRunning && (
|
||||
<Alert variant="warning">
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
|
||||
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{makerRunning && (
|
||||
<Alert variant="warning">
|
||||
<HourglassIcon className="motion-safe:animate-pulse" />
|
||||
<AlertDescription>{t('send.text_maker_running')}</AlertDescription>
|
||||
<AlertTitle>{t('send.text_maker_running')}</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
|
@ -435,7 +450,11 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
|
||||
{!currentSchedule && !schedulerRunning && (
|
||||
<>
|
||||
<Card>
|
||||
<Card
|
||||
className={cn('transition-all duration-500', {
|
||||
'blur-[2px]': takerRunning || makerRunning,
|
||||
})}
|
||||
>
|
||||
<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">
|
||||
|
|
@ -452,6 +471,7 @@ export const SweepPage = ({ walletFileName }: SweepPageProps) => {
|
|||
<SweepForm
|
||||
jars={walletInfo.jars}
|
||||
addressSummary={walletInfo.addressSummary}
|
||||
network={network}
|
||||
disabled={isOperationDisabled || isWaitingSchedulerStart || isWaitingSchedulerStop}
|
||||
debug={isDeveloperMode}
|
||||
onSubmit={async (values) => {
|
||||
|
|
|
|||
|
|
@ -1,84 +1,10 @@
|
|||
import { AlertTriangleIcon } from 'lucide-react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { SweepPreconditionSummary } from '@/components/sweep/preconditions'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { JAM_JM_RETRIES_DOCS_URL } from '@/constants/jam'
|
||||
import { formatSats, shortenStringMiddle } from '@/lib/utils'
|
||||
import { PreconditionAlert } from '@/components/ui/jam/PreconditionAlert'
|
||||
|
||||
interface SweepPreconditionAlertProps {
|
||||
summary: SweepPreconditionSummary
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <PreconditionAlert summary={summary} i18nPrefix="scheduler.precondition" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ describe('buildDestinationErrors', () => {
|
|||
})
|
||||
|
||||
it('reports duplicate addresses', () => {
|
||||
const validAddress = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
|
||||
const validAddress = '1BitcoinEaterAddressDontSend8MUo1T'
|
||||
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 = '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'
|
||||
const freshAddress = '3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy'
|
||||
const usedAddress = '1BitcoinEaterAddressDontSend8MUo1T'
|
||||
const freshAddress = '1BitcoinEaterAddressDontSendDHyNcX'
|
||||
const addressSummary = {
|
||||
[usedAddress]: {
|
||||
used: true,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
|||
import type { Utxo } from '@/hooks/useQueryUtxos'
|
||||
import { buildSweepPreconditionSummary } from './preconditions'
|
||||
|
||||
const utxo = (overrides: Partial<Utxo>): Utxo => {
|
||||
const makeUtxo = (overrides: Partial<Utxo>): Utxo => {
|
||||
return {
|
||||
utxo: 'txid:0',
|
||||
address: 'bc1qexampleaddress000000000000000000000000000',
|
||||
|
|
@ -22,45 +22,72 @@ const utxo = (overrides: Partial<Utxo>): Utxo => {
|
|||
|
||||
describe('buildSweepPreconditionSummary', () => {
|
||||
it('returns fulfilled when at least one eligible utxo is available', () => {
|
||||
const summary = buildSweepPreconditionSummary([utxo({})])
|
||||
const summary = buildSweepPreconditionSummary([makeUtxo({})])
|
||||
|
||||
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([utxo({ confirmations: 2 })], { minConfirmations: 5 })
|
||||
const summary = buildSweepPreconditionSummary([makeUtxo({ 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([
|
||||
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 }),
|
||||
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 }),
|
||||
])
|
||||
|
||||
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 or timelocked utxos when counting eligibility', () => {
|
||||
it('ignores frozen utxos when counting eligibility', () => {
|
||||
const summary = buildSweepPreconditionSummary([
|
||||
utxo({ utxo: 'a:0', frozen: true }),
|
||||
utxo({
|
||||
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: 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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as fb from '@/lib/fidelityBondUtils'
|
|||
export interface SweepPreconditionOptions {
|
||||
minNumberOfUtxos: number
|
||||
minConfirmations: number
|
||||
maxNumberOfNonFrozenFidelityBondOutputs: number
|
||||
}
|
||||
|
||||
export interface SweepPreconditionSummary {
|
||||
|
|
@ -12,17 +13,15 @@ export interface SweepPreconditionSummary {
|
|||
options: SweepPreconditionOptions
|
||||
numberOfMissingUtxos: number
|
||||
numberOfMissingConfirmations: number
|
||||
numberOfNonFrozenFidelityBondOutputs: number
|
||||
retryLockedUtxos: Utxo[]
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: SweepPreconditionOptions = {
|
||||
export const SWEEP_PRECONDITION_DEFAULT_OPTIONS: SweepPreconditionOptions = Object.freeze({
|
||||
minNumberOfUtxos: 1,
|
||||
minConfirmations: JM_TAKER_UTXO_AGE,
|
||||
}
|
||||
|
||||
const isUtxoEligible = (utxo: Utxo): boolean => {
|
||||
return !utxo.frozen && !fb.utxo.isFidelityBond(utxo)
|
||||
}
|
||||
maxNumberOfNonFrozenFidelityBondOutputs: 0,
|
||||
})
|
||||
|
||||
const groupByJarIndex = (utxos: Utxo[]): Map<number, Utxo[]> => {
|
||||
return utxos.reduce((acc, utxo) => {
|
||||
|
|
@ -65,11 +64,35 @@ export const buildSweepPreconditionSummary = (
|
|||
options: Partial<SweepPreconditionOptions> = {},
|
||||
): SweepPreconditionSummary => {
|
||||
const mergedOptions: SweepPreconditionOptions = {
|
||||
...DEFAULT_OPTIONS,
|
||||
...SWEEP_PRECONDITION_DEFAULT_OPTIONS,
|
||||
...options,
|
||||
}
|
||||
|
||||
const eligibleUtxos = utxos.filter((utxo) => isUtxoEligible(utxo))
|
||||
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 numberOfMissingUtxos = Math.max(0, mergedOptions.minNumberOfUtxos - eligibleUtxos.length)
|
||||
const numberOfMissingConfirmations =
|
||||
numberOfMissingUtxos > 0 ? 0 : calcMissingConfirmations(eligibleUtxos, mergedOptions.minConfirmations)
|
||||
|
|
@ -79,6 +102,7 @@ export const buildSweepPreconditionSummary = (
|
|||
return {
|
||||
isFulfilled: numberOfMissingUtxos === 0 && numberOfMissingConfirmations === 0 && retryLockedUtxos.length === 0,
|
||||
options: mergedOptions,
|
||||
numberOfNonFrozenFidelityBondOutputs: 0,
|
||||
numberOfMissingUtxos,
|
||||
numberOfMissingConfirmations,
|
||||
retryLockedUtxos,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ 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: {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ 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 { Balance } from './Balance'
|
||||
import '@/i18n/config'
|
||||
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
|
||||
|
||||
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
|
||||
const providers = ({ children }: { children: React.ReactNode }) => {
|
||||
|
|
@ -35,11 +37,13 @@ describe('<Balance />', () => {
|
|||
})
|
||||
|
||||
it('should render balance in SATS', () => {
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render a string BTC value correctly as BTC', () => {
|
||||
|
|
@ -49,9 +53,11 @@ describe('<Balance />', () => {
|
|||
})
|
||||
|
||||
it('should render a string BTC value correctly as SATS', () => {
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render a zero string BTC value correctly as BTC', () => {
|
||||
|
|
@ -65,23 +71,31 @@ describe('<Balance />', () => {
|
|||
})
|
||||
|
||||
it('should render a large string BTC value correctly as BTC', () => {
|
||||
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
|
|
@ -105,23 +119,31 @@ describe('<Balance />', () => {
|
|||
})
|
||||
|
||||
it('should render a large string SATS value correctly as BTC', () => {
|
||||
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
|
||||
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
|
||||
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
|
||||
withRuntimeLocale('en-US', () => {
|
||||
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', () => {
|
||||
|
|
@ -132,10 +154,12 @@ describe('<Balance />', () => {
|
|||
})
|
||||
|
||||
it('should render frozen balance in SATS', () => {
|
||||
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()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render balance without symbol', () => {
|
||||
|
|
@ -198,4 +222,51 @@ 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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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'
|
||||
|
|
@ -23,6 +24,8 @@ type ElementWithSymbolsProps = PropsWithChildren<{
|
|||
frozenSymbol?: boolean
|
||||
className?: string
|
||||
onClick?: MouseEventHandler
|
||||
'aria-label'?: string
|
||||
'aria-pressed'?: boolean
|
||||
}>
|
||||
|
||||
const ElementWithSymbols = ({
|
||||
|
|
@ -33,23 +36,40 @@ const ElementWithSymbols = ({
|
|||
className,
|
||||
children,
|
||||
onClick,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-pressed': ariaPressed,
|
||||
}: ElementWithSymbolsProps) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'balance-hook inline-flex items-center',
|
||||
{
|
||||
'text-brand-info': frozen,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
const sharedClassName = cn(
|
||||
'balance-hook inline-flex items-center',
|
||||
{
|
||||
'text-brand-info': frozen,
|
||||
},
|
||||
className,
|
||||
)
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{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 = '.'
|
||||
|
|
@ -156,6 +176,7 @@ export const BalanceComponent = ({
|
|||
enableVisibilityToggle,
|
||||
...props
|
||||
}: BalanceComponentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isBalanceVisible, setIsBalanceVisible] = useState(showBalance)
|
||||
const displayMode = useMemo<DisplayMode>(() => {
|
||||
return isBalanceVisible ? (convertToUnit ?? 'default') : 'hidden'
|
||||
|
|
@ -173,18 +194,25 @@ 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': onClickHandler || props.onClick,
|
||||
'cursor-pointer': isInteractive,
|
||||
}),
|
||||
onClick: (event: MouseEvent<HTMLSpanElement>) => {
|
||||
onClickHandler?.(event)
|
||||
props.onClick?.(event)
|
||||
},
|
||||
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,
|
||||
}
|
||||
}, [props, enableVisibilityToggle])
|
||||
}, [props, enableVisibilityToggle, isBalanceVisible, t])
|
||||
|
||||
const element = useMemo(() => {
|
||||
if (displayMode === 'hidden') {
|
||||
|
|
|
|||
163
src/components/ui/jam/CookingPotIcon.tsx
Normal file
163
src/components/ui/jam/CookingPotIcon.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
70
src/components/ui/jam/Jar.test.tsx
Normal file
70
src/components/ui/jam/Jar.test.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { type Jar as JarType } from '@/context/JamWalletInfoContext'
|
||||
import type { AmountSats } from '@/types/global'
|
||||
import { Jar } from './Jar'
|
||||
|
||||
vi.mock('@/components/ui/jam/Balance', () => ({
|
||||
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
|
||||
<span onClick={onClick}>balance:{valueString}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
const DUMMY_JAR: JarType = {
|
||||
balanceSummary: {
|
||||
calculatedTotalBalanceInSats: 5,
|
||||
calculatedAvailableBalanceInSats: 4,
|
||||
calculatedConfirmedAvailableBalanceInSats: 3,
|
||||
calculatedAvailableFrozenBalanceInSats: 2,
|
||||
calculatedFrozenOrLockedBalanceInSats: 1,
|
||||
},
|
||||
color: '#666',
|
||||
jarIndex: 0,
|
||||
name: 'Test jar',
|
||||
utxos: [],
|
||||
}
|
||||
|
||||
describe('Jar', () => {
|
||||
it('renders correctly', () => {
|
||||
render(
|
||||
<Jar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats).parentNode,
|
||||
).not.toHaveClass('invisible')
|
||||
})
|
||||
it('does not render zero frozen balance', () => {
|
||||
const frozenBalance: AmountSats = 0
|
||||
|
||||
render(
|
||||
<Jar
|
||||
name={DUMMY_JAR.name}
|
||||
color={DUMMY_JAR.color}
|
||||
frozenOrLockedBalance={frozenBalance}
|
||||
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
|
||||
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
|
||||
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
|
||||
isSelected={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + frozenBalance)).toBeInTheDocument()
|
||||
expect(screen.getByText('balance:' + frozenBalance).parentNode).toHaveClass('invisible')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue