Compare commits

..

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

411 changed files with 9209 additions and 19948 deletions

16
.env
View file

@ -1,28 +1,14 @@
VITE_JAM_DEFAULT_THEME=dark
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
VITE_JAM_JMNG_REPO_URL=https://github.com/joinmarket-ng/joinmarket-ng
VITE_JM_API_BASE_URL=
VITE_JM_API_BASE_URL=/api/v1
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=1800
VITE_JAM_JM_SESSION_REFRESH_INTERVAL=30000
VITE_JAM_RESCAN_PROGRESS_INTERVAL=10000
VITE_JAM_SEED_MODAL_TIMEOUT=30000
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=3
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JM_WEBSOCKET_ENDPOINT_PATH=/jmws
VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION=1000
VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION=3000
VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL=30000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=5000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000
VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10
VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS=true
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1000

View file

@ -5,13 +5,3 @@ VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=3000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=30000
VITE_JAM_SEED_MODAL_TIMEOUT=21000
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=59
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=59
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1
VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL=5000

View file

@ -1,7 +0,0 @@
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +1 @@
npm run test -- --bail=1
npm test

2
.nvmrc
View file

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

View file

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

View file

@ -5,12 +5,12 @@ The aim of this document is to help you get setup for participating in all areas
## Understanding the Components Involved
Jam is a web UI for [JoinMarket NG](https://github.com/joinmarket-ng/joinmarket-ng) with focus on user-friendliness.
The web UI's purpose is to be a rather lightweight front end for the JoinMarket NG API.
To function, the web UI needs to connect to an instance of JoinMarket NG with [the API service](https://joinmarket-ng.github.io/joinmarket-ng/README-jmwalletd/) running.
Jam is a web UI for [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver/) with focus on user-friendliness.
The web UI's purpose is to be a rather lightweight front end for the JoinMarket API.
To function, the web UI needs to connect to an instance of JoinMarket with [the API service](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/JSON-RPC-API-using-jmwalletd.md) running.
You don't need to worry about that, though.
To ease development and testing, we provide a Docker setup that runs JoinMarket NG and its API service in a regtest setup.
To ease development and testing, we provide a Docker setup that runs JoinMarket and its API service in a regtest setup.
## Local setup (regtest and UI)
@ -22,7 +22,7 @@ Install dependencies:
npm install
```
Start the JoinMarket NG HTTP API service in regtest.
Start the JoinMarket HTTP API service in regtest.
It isn't needed to dig deeper into how it works to use it.
However, if you want to find out more about it, see [docker/regtest/readme.md](docker/regtest/readme.md).

View file

@ -19,7 +19,7 @@
---
Jam is a web interface for [JoinMarket NG](https://github.com/joinmarket-ng/joinmarket-ng) focusing on user-friendliness and ease-of-use.
Jam is a web interface for [JoinMarket](https://github.com/JoinMarket-Org/joinmarket-clientserver/) focusing on user-friendliness and ease-of-use.
It aims to provide sensible defaults and be easy to use for beginners while still having the features advanced users expect.
- 💬 Join our [Matrix room](https://matrix.to/#/%23jam:bitcoin.kyoto) to get help and [contribute](https://joinmarket-webui.github.io/jamdocs/contribute/)!
@ -88,4 +88,4 @@ Want to get your hands dirty with code? Great! [CONTRIBUTING.md](CONTRIBUTING.md
This project builds upon the [jm-web-client](https://github.com/JoinMarket-Org/jm-web-client) which was developed by [Shobhitaa](https://github.com/shobhitaa), [Abhishek](https://github.com/abhishek0405), and [waxwing](https://github.com/AdamISZ) himself. Many people contributed over time, some of which are [listed here](https://github.com/joinmarket-webui/jam/graphs/contributors).
Jam and JoinMarket NG are separate projects. For more information on JoinMarket NG, see the [JoinMarket NG GitHub Page](https://github.com/joinmarket-ng/joinmarket-ng).
Jam and JoinMarket are separate projects. For more information on JoinMarket, see the [JoinMarket GitHub Page](https://github.com/JoinMarket-Org/joinmarket-clientserver).

View file

@ -12,6 +12,10 @@ services:
jm_rpc_host: bitcoind
jm_rpc_port: 43782
jm_directory_nodes: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
jm_socks5_host: tor
jm_socks5_port: 9050
jm_tor_control_host: tor
jm_tor_control_port: 9051
jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4
jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5
jm_maker_timeout_sec: 30 # easier testing of maker timeouts on regtest (and "Stall Monitor" retries); default is 60
@ -41,6 +45,10 @@ services:
jm_rpc_host: bitcoind
jm_rpc_port: 43782
jm_directory_nodes: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
jm_socks5_host: tor
jm_socks5_port: 9050
jm_tor_control_host: tor
jm_tor_control_port: 9051
jm_minimum_makers: 1 # necessary to do coinjoins with this regtest setup; default is 4
jm_taker_utxo_age: 1 # faster testing of scheduler runs; default is 5
expose:
@ -59,16 +67,6 @@ services:
context: ./dockerfile-deps/joinmarket/webui-standalone-ng
dockerfile: Dockerfile
restart: unless-stopped
environment:
JMWALLETD_HOST: 0.0.0.0
BITCOIN__BACKEND_TYPE: descriptor_wallet
BITCOIN__RPC_URL: http://bitcoind:43782
BITCOIN__RPC_USER: regtest
BITCOIN__RPC_PASSWORD: regtest
LOGGING__LEVEL: DEBUG
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}
expose:
- 80 # nginx
- 28183 # jmwalletd api
@ -79,6 +77,7 @@ services:
environment:
JOINMARKET_DATA_DIR: /root/.joinmarket-ng
JMWALLETD_HOST: 0.0.0.0
JMWALLETD_NO_TLS: ${JMWALLETD_NO_TLS:-false}
BITCOIN__BACKEND_TYPE: descriptor_wallet
BITCOIN__RPC_URL: http://bitcoind:43782
BITCOIN__RPC_USER: regtest
@ -86,8 +85,7 @@ 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
DIRECTORY_NODES: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
@ -99,6 +97,7 @@ services:
expose:
- 8000 # obwatch
- 28183 # jmwalletd api
- 28283 # jmwalletd websocket
healthcheck:
test:
[
@ -119,6 +118,7 @@ services:
environment:
JOINMARKET_DATA_DIR: /root/.joinmarket-ng
JMWALLETD_HOST: 0.0.0.0
JMWALLETD_NO_TLS: ${JMWALLETD_NO_TLS:-false}
BITCOIN__BACKEND_TYPE: descriptor_wallet
BITCOIN__RPC_URL: http://bitcoind:43782
BITCOIN__RPC_USER: regtest
@ -126,8 +126,7 @@ 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
DIRECTORY_NODES: ${JM_ALL_DIRECTORY_NODES:?You must set the directory node addresses in generated env file}
OBWATCH_URL: http://joinmarket_ng_orderbook_watcher:8000
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
@ -138,6 +137,7 @@ services:
expose:
- 8000 # obwatch
- 28183 # jmwalletd api
- 28283 # jmwalletd websocket
healthcheck:
test:
[

View file

@ -6,10 +6,12 @@ services:
service: joinmarket_ng_native
environment:
READY_FILE: /root/.regtest-initializer/btc_fully_synched
BITCOIN__RPC_USER: joinmarket
BITCOIN__RPC_PASSWORD: joinmarket
BITCOIN__RPC_USER: regtest
BITCOIN__RPC_PASSWORD: regtest
BITCOIN__DESCRIPTOR_WALLET_NAME: jm_primary
ports:
- '28183:28183' # jmwalletd api (exposed for "init setup" routine)
- '28183:28183' # jmwalletd api
- '28283:28283' # jmwalletd websocket
volumes:
- 'joinmarket_datadir:/root/.joinmarket-ng'
- 'initializer_datadir:/root/.regtest-initializer'
@ -30,13 +32,20 @@ services:
file: docker-compose-common.yml
service: joinmarket_jam_standalone_ng
environment:
BITCOIN__RPC_USER: joinmarket2
BITCOIN__RPC_PASSWORD: joinmarket2
ENSURE_WALLET: 'true'
BITCOIN__RPC_URL: http://bitcoind:43782
BITCOIN__RPC_USER: regtest
BITCOIN__RPC_PASSWORD: regtest
BITCOIN__DESCRIPTOR_WALLET_NAME: jm_secondary
LOGGING__LEVEL: DEBUG
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}
TAKER__MINIMUM_MAKERS: 2 # necessary to do coinjoins with this regtest setup
TAKER__MAKER_TIMEOUT_SEC: 30 # easier testing of maker timeouts on regtest
ports:
- '29080:80'
- '29183:28183' # jmwalletd api (exposed for "init setup" routine)
- '29183:28183' # exposed for "init setup" routine
volumes:
- 'joinmarket2_datadir:/root/.joinmarket-ng'
depends_on:
@ -55,11 +64,10 @@ services:
jm_rpc_wallet_file: jm_tertiary
jm_rpc_user: joinmarket3
jm_rpc_password: joinmarket3
CREATE_WALLET_PARAMS_DESCRIPTORS: 'false'
ports:
- '30601:62601' # obwatch
- '30183:28183' # jmwalletd api (exposed for "init setup" routine)
- '30283:28283' # jmwalletd websocket
- '30601:62601' # obwatch
- '30183:28183' # jmwalletd api
- '30283:28283' # jmwalletd websocket
volumes:
- 'joinmarket3_datadir:/root/.joinmarket'
- 'initializer_datadir:/root/.regtest-initializer'
@ -85,7 +93,7 @@ services:
jm_rpc_cookie_file: /root/.bitcoin_data/regtest/.cookie
ports:
- '31080:80'
- '31183:28183' # jmwalletd api (exposed for "init setup" routine)
- '31183:28183' # exposed for "init setup" routine
volumes:
- 'joinmarket4_datadir:/root/.joinmarket'
- 'bitcoin_datadir:/root/.bitcoin_data' # mount bitcoind dir to access .cookie file
@ -105,8 +113,12 @@ services:
extends:
file: docker-compose-common.yml
service: joinmarket_ng_peer
environment:
BITCOIN__RPC_USER: regtest
BITCOIN__RPC_PASSWORD: regtest
BITCOIN__DESCRIPTOR_WALLET_NAME: jm_quinary
ports:
- '32183:28183' # jmwalletd api (exposed for "init setup" routine)
- '32183:28183'
volumes:
- 'joinmarket5_datadir:/root/.joinmarket-ng'
- 'tor_datadir:/var/lib/tor:ro'
@ -120,8 +132,8 @@ services:
tor:
condition: service_healthy
joinmarket_clientserver_directory_node:
container_name: jm_regtest_joinmarket_clientserver_directory_node
joinmarket_directory_node:
container_name: jm_regtest_joinmarket_directory_node
build:
context: ./dockerfile-deps/joinmarket/directory_node
dockerfile: Dockerfile
@ -129,10 +141,15 @@ services:
environment:
jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir
jm_directory_nodes: ${JM_REF_DIRECTORY_NODES:?You must set the reference directory node address in generated env file}
jm_socks5_host: localhost
jm_socks5_port: 9050
jm_tor_control_host: localhost
jm_tor_control_port: 9051
jm_daemon_host: 0.0.0.0
jm_blockchain_source: regtest
jm_network: testnet
volumes:
- 'joinmarket_clientserver_directory_node_datadir:/root/.joinmarket'
- 'joinmarket_directory_node_datadir:/root/.joinmarket'
- './out/hidden_service_dir:/root/.joinmarket/hidden_service_dir:z'
healthcheck:
test: ['CMD', 'supervisorctl', 'status']
@ -144,6 +161,9 @@ services:
depends_on:
tor:
condition: service_healthy
networks:
default:
ipv4_address: 172.30.0.20
joinmarket_ng_orderbook_watcher:
container_name: jm_regtest_joinmarket_ng_orderbook_watcher
@ -156,8 +176,6 @@ services:
TOR__SOCKS_HOST: tor
TOR__SOCKS_PORT: 9050
LOGGING__LEVEL: DEBUG
ORDERBOOK_WATCHER__HTTP_HOST: 0.0.0.0
ORDERBOOK_WATCHER__HTTP_PORT: 8000
expose:
- 8000
ports:
@ -225,10 +243,11 @@ services:
[
'sh',
'-c',
'mkdir -p /var/lib/tor/ng-directory && cp -f /source/ng/* /var/lib/tor/ng-directory/ && chown -R 1000:1000 /var/lib/tor && chmod 700 /var/lib/tor/ng-directory && chmod 600 /var/lib/tor/ng-directory/*',
'mkdir -p /var/lib/tor/ref-directory /var/lib/tor/ng-directory && cp -f /source/ref/* /var/lib/tor/ref-directory/ && cp -f /source/ng/* /var/lib/tor/ng-directory/ && chown -R 1000:1000 /var/lib/tor && chmod 700 /var/lib/tor/ref-directory /var/lib/tor/ng-directory && chmod 600 /var/lib/tor/ref-directory/* /var/lib/tor/ng-directory/*',
]
volumes:
- 'tor_datadir:/var/lib/tor'
- './out/hidden_service_dir:/source/ref:ro,z'
- './out/ng_directory_hidden_service:/source/ng:ro,z'
irc:
@ -243,7 +262,7 @@ services:
bitcoind:
container_name: jm_regtest_bitcoind
restart: unless-stopped
image: polarlightning/bitcoind:29.0@sha256:4521294ac25fdbf1492a8cc5ea987463a5f244b82546d29bbe8451db39f36f3a
image: polarlightning/bitcoind:28.0@sha256:ff435d758c89f01823924e057d5d5e4206d350ae69dff5e0dc1bbc9c6a36d010
command:
# rpcauth (user=regtest; password=regtest)
# rpcauth (user=joinmarket; password=joinmarket)
@ -369,7 +388,7 @@ volumes:
joinmarket3_datadir:
joinmarket4_datadir:
joinmarket5_datadir:
joinmarket_clientserver_directory_node_datadir:
joinmarket_directory_node_datadir:
tor_datadir:
irc_datadir:

View file

@ -9,7 +9,7 @@ and provide the correct onion hostname via `directory_nodes` yourself!
e.g. in a docker-compose setup:
```yml
joinmarket_clientserver_directory_node:
joinmarket_directory_node:
[...]
environment:
jm_hidden_service_dir: \/root\/.joinmarket\/hidden_service_dir

View file

@ -93,7 +93,7 @@ if [ "${ENSURE_WALLET}" = "true" ]; then
\"method\":\"createwallet\",\
\"params\":{\
\"wallet_name\":\"${wallet_name}\",\
\"descriptors\":$( [[ "${CREATE_WALLET_PARAMS_DESCRIPTORS,,}" == "false" ]] && echo false || echo true ),\
\"descriptors\":false,\
\"load_on_startup\":true\
}\
}"

View file

@ -1,7 +1,7 @@
# Default JoinMarket Tor configuration
# taken from v0.9.6 on 2022-07-18:
# https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/v0.9.6/install.sh#L388-L393
Log notice stdout
Log warn stderr
SOCKSPort 9050 IsolateDestAddr IsolateDestPort
ControlPort 9051
CookieAuthentication 1

View file

@ -16,9 +16,7 @@ set -Eeuo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
# fund wallet in primary JoinMarket NG container
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 0 --unmatured --blocks 1
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 1 --unmatured --blocks 2 # outputs should be flagged as "reused"
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --mixdepth 4 --unmatured --blocks 1
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket --unmatured --blocks 3
# fund wallet in ng standalone secondary container
. "$script_dir/fund-wallet.sh" --container jm_regtest_joinmarket2 --unmatured --blocks 50
@ -37,14 +35,10 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0)
dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7)
dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21)
dummy_wallet_address_fb0='bcrt1q7war3lusq3ez633rzzfkp75unfcgrqqcu80sgp0ellhrdvkzxh5swspygk' # 1st fb address (m/84'/1'/0'/2/0)
dummy_wallet_address_fb959='bcrt1q6rlcknptmqhf4w20928tpz6px4y70960gyupd029c7vx7kdjwt0q46nk9c' # 960th fb address (m/84'/1'/0'/2/959)
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = at least 106 blocks
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null
. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb0" &>/dev/null
. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb959" &>/dev/null
. "$script_dir/mine-block.sh" 100 &>/dev/null
start_maker() {

View file

@ -120,7 +120,7 @@ Port mapping overview:
| Container | Ports |
| --------------------------------------- | ---------------------------------------------- |
| `joinmarket` (ng native) | `28183` (API) |
| `joinmarket` (ng native) | `28183` (API), `28283` (WS), `62601` (obwatch) |
| `joinmarket2` (ng standalone) | `29080` (UI), `29183` (API) |
| `joinmarket3` (clientserver native) | `30183` (API), `30283` (WS), `30601` (obwatch) |
| `joinmarket4` (clientserver standalone) | `31080` (UI, basic auth), `31183` (API) |

View file

@ -9,6 +9,11 @@ ControlPort 0.0.0.0:9051
CookieAuthentication 1
CookieAuthFile /var/lib/tor/control_auth_cookie
# Hidden service for JoinMarket reference directory node
HiddenServiceDir /var/lib/tor/ref-directory
HiddenServicePort 5222 172.30.0.20:8080
HiddenServiceVersion 3
# Hidden service for JoinMarket NG directory node
HiddenServiceDir /var/lib/tor/ng-directory
HiddenServicePort 5222 172.30.0.21:5223

View file

@ -6,9 +6,9 @@ A place to collect useful information for developers that doesn't really fit els
## JoinMarket Development Environment
For a complete development environment you need a local JoinMarket NG instance that the web UI can interact with. We provide a regtest environment that should give you everything needed to get started developing with JoinMarket NG. You can find details here: [docker/regtest/readme.md](../docker/regtest/readme.md).
For a complete development environment you need a local JoinMarket instance that the web UI can interact with. We provide a regtest environment that should give you everything needed to get started developing with JoinMarket. You can find details here: [docker/regtest/readme.md](../docker/regtest/readme.md).
## Running Jam Against JoinMarket NG
## Running Jam Against JoinMarket-NG
Jam v2 can talk directly to a separately running `jmwalletd` / orderbook watcher from `joinmarket-ng`. You do not need to run the Jam regtest compose or the reference implementation for this workflow.
@ -26,7 +26,7 @@ If your separately running joinmarket-ng services use different ports, you can o
JAM_BACKEND=joinmarket-ng \
JMWALLETD_API_PORT=28183 \
JMWALLETD_WEBSOCKET_PORT=28283 \
JMOBWATCH_PORT=8000 \
JMOBWATCH_PORT=8080 \
npm run dev
```
@ -86,17 +86,17 @@ By default, the hook will be installed automatically as an [NPM postinstall scri
If you're having issues with Husky not using the correct `$PATH`, you may need to setup a `~/.huskyrc` which will let you set up your path before the hook is run.
See [here](https://typicode.github.io/husky/#/?id=command-not-found) for more info.
## Running the Web UI Locally and Connecting to a Remote JoinMarket NG Instance
## Running the Web UI Locally and Connecting to a Remote JoinMarket Instance
These instructions assume you want to run the web UI locally and connect it to a JoinMarket NG instance on your RaspiBlitz.
These instructions assume you want to run the web UI locally and connect it to a JoinMarket instance on your RaspiBlitz.
The process should be similar for other setups.
If you run the Web UI and JoinMarket NG on the same system, simply skip the SSH tunnel step.
If you run the Web UI and JoinMarket on the same system, simply skip the SSH tunnel step.
### 🚨 Prerequisite: JoinMarket NG
### 🚨 Prerequisite: JoinMarket
To run the web UI locally you need to connect it to a running JoinMarket NG instance.
To run the web UI locally you need to connect it to a running JoinMarket instance.
#### 1. Install JoinMarket NG
#### 1. Install JoinMarket
Install [JoininBox](https://github.com/openoms/joininbox) on your [RaspiBlitz](https://github.com/rootzoll/raspiblitz):
@ -104,38 +104,38 @@ Install [JoininBox](https://github.com/openoms/joininbox) on your [RaspiBlitz](h
Services > j [BTC JoinMarket+JoininBox menu]
```
Or follow the JoinMarket NG [installation guide](https://joinmarket-ng.github.io/joinmarket-ng/install/) if you're on another system.
Or follow the JoinMarket [installation guide](https://github.com/JoinMarket-Org/joinmarket-clientserver#quickstart---recommended-installation-method-linux-and-macos-only) if you're on another system.
### 🚨 Prerequisite: JoinMarket NG API Service
### 🚨 Prerequisite: JoinMarket API Service
This app makes use of the JoinMarket NG API.
This app makes use of the JoinMarket RPC API. For this, you will need JoinMarket version 0.9.3 or higher. If needed you can upgrade JoinMarket to the latest commit via the JoininBox menu on your RaspiBlitz: Type `jm` in the command line and select `UPDATE > ADVANCED > JMCOMMIT`. This will install the latest development version from JoinMarket's master branch.
#### 2. SSL Certificate
As the joinmarket user on your RaspiBlitz, generate a self-signed certificate for the JoinMarket NG API Service as described [here](https://linuxize.com/post/creating-a-self-signed-ssl-certificate/), and put the certificate and the key in the `~/.joinmarket-ng/ssl/` directory.
As the joinmarket user on your RaspiBlitz, generate a self-signed certificate for the JoinMarket API Service as described [here](https://linuxize.com/post/creating-a-self-signed-ssl-certificate/), and put the certificate and the key in the `~/.joinmarket/ssl/` directory.
_Hint:_ To login as the JoinMarket user, you can ssh into your RaspiBlitz, type `jm`, and exit the JoininBox menu.
Create the SSL directory:
```bash
(jmvenv) joinmarket@raspberrypi:~ $ mkdir ~/.joinmarket-ng/ssl/
(jmvenv) joinmarket@raspberrypi:~ $ mkdir ~/.joinmarket/ssl/
```
Generate the certificate and associated key:
```bash
openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out ~/.joinmarket-ng/ssl/cert.pem -keyout ~/.joinmarket-ng/ssl/key.pem
openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out ~/.joinmarket/ssl/cert.pem -keyout ~/.joinmarket/ssl/key.pem
```
_Hint:_ You don't have to enter anything meaningful, you can just hit the return key a couple of times.
#### 3. API Service
Start the JoinMarket NG [API service](https://joinmarket-ng.github.io/joinmarket-ng/README-jmwalletd/):
Start the JoinMarket [API service](https://github.com/JoinMarket-Org/joinmarket-clientserver/blob/master/docs/JSON-RPC-API-using-jmwalletd.md):
```bash
(jmvenv) joinmarket@raspberrypi:~/joinmarket-ng $ jmwalletd serve
(jmvenv) joinmarket@raspberrypi:~/joinmarket-clientserver/scripts $ python jmwalletd.py
```
You should see the following:

View file

@ -125,12 +125,6 @@ export default defineConfig(
},
},
],
'unicorn/number-literal-case': [
'error',
{
hexadecimalValue: 'lowercase',
},
],
'unicorn/switch-case-braces': ['off'], // TODO: enable
'unicorn/no-null': ['off'], // TODO: enable

View file

@ -2,12 +2,11 @@
<html lang="en" suppressHydrationWarning>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self';">
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#000000" />
<link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Jam for JoinMarket</title>
</head>
<body>

4657
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,14 @@
{
"name": "jam",
"version": "2.0.0-beta.2",
"version": "2.0.0-beta.0",
"private": true,
"description": "Your sats. Your privacy. Your profit.",
"repository": "git@github.com:joinmarket-webui/jam.git",
"license": "MIT",
"type": "module",
"engines": {
"node": ">=26.3.0",
"npm": ">=11.16.0"
"node": ">=24.11.1",
"npm": ">=11.6.2"
},
"scripts": {
"dev": "npm run dev:ng:native",
@ -32,7 +32,6 @@
"test": "NODE_OPTIONS=--no-webstorage vitest run",
"test:unit": "NODE_OPTIONS=--no-webstorage vitest run --project unit",
"test:storybook": "NODE_OPTIONS=--no-webstorage vitest run --project storybook",
"test:coverage": "npm test -- --coverage.enabled",
"check": "npm run format && npm run test && npm run build",
"regtest:build": "npm run regtest:clear && npm run regtest:pull && docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml build --pull",
"regtest:pull": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml pull",
@ -42,18 +41,14 @@
"regtest:down": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml down",
"regtest:logs:all": "docker compose --env-file docker/regtest/.env.example --file docker/regtest/docker-compose.yml logs --follow",
"regtest:logs": "npm run regtest:logs:ng:native:jmwalletd",
"regtest:logs:obwatch": "npm run regtest:logs:ng:obwatch",
"regtest:logs:directory_node": "npm run regtest:logs:ng:directory_node",
"regtest:logs:ng:native:jmwalletd": "docker logs -f jm_regtest_joinmarket",
"regtest:logs:ng:obwatch": "docker logs -f jm_regtest_joinmarket_ng_orderbook_watcher",
"regtest:logs:ng:directory_node": "docker logs -f jm_regtest_joinmarket_ng_directory_node",
"regtest:logs:clientserver:directory_node": "docker exec -t jm_regtest_joinmarket_clientserver_directory_node tail -f /var/log/jam/directory_node_stdout.log -n 200",
"regtest:logs:obwatch": "npm run regtest:logs:ng:obwatch",
"regtest:logs:ng:directory_node": "docker exec -t jm_regtest_joinmarket_directory_node tail -f /var/log/jam/directory_node_stdout.log -n 200",
"regtest:logs:clientserver:native:jmwalletd": "docker exec -t jm_regtest_joinmarket3 tail -f /var/log/jam/jmwalletd_stdout.log",
"regtest:logs:clientserver:native:tor": "docker exec -t jm_regtest_joinmarket3 tail -f /var/log/jam/tor_stdout.log",
"regtest:logs:clientserver:native:schedule": "docker exec -t jm_regtest_joinmarket3 tail -f /root/.joinmarket/logs/TUMBLE.schedule",
"regtest:init": "./docker/regtest/init-setup.sh",
"regtest:mine": "watch -n 11 ./docker/regtest/mine-block.sh",
"regtest:listwallets": "docker exec -t jm_regtest_bitcoind bitcoin-cli -datadir=/home/bitcoin/data -regtest -rpcport=43782 listwallets"
"regtest:mine": "watch -n 11 ./docker/regtest/mine-block.sh"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@ -71,97 +66,96 @@
]
},
"dependencies": {
"@hookform/resolvers": "5.7.1",
"@joinmarket-webui/joinmarket-ng-api-ts": "1.0.0",
"@noble/hashes": "2.3.0",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-tooltip": "1.2.16",
"@hookform/resolvers": "5.4.0",
"@joinmarket-webui/joinmarket-api-ts": "0.3.0",
"@noble/hashes": "2.2.0",
"@radix-ui/react-accordion": "1.2.14",
"@radix-ui/react-avatar": "1.2.0",
"@radix-ui/react-checkbox": "1.3.5",
"@radix-ui/react-dialog": "1.1.17",
"@radix-ui/react-dropdown-menu": "2.1.18",
"@radix-ui/react-label": "2.1.10",
"@radix-ui/react-popover": "1.1.17",
"@radix-ui/react-radio-group": "1.4.1",
"@radix-ui/react-select": "2.3.1",
"@radix-ui/react-separator": "1.1.10",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-switch": "1.3.1",
"@radix-ui/react-tabs": "1.1.15",
"@radix-ui/react-tooltip": "1.2.10",
"@scure/bip32": "2.2.0",
"@scure/bip39": "2.2.0",
"@tailwindcss/vite": "4.3.3",
"@tailwindcss/vite": "4.3.1",
"@tanstack/match-sorter-utils": "8.19.4",
"@tanstack/react-query": "5.101.4",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-table": "8.21.3",
"bitcoin-address-validation": "3.0.0",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"html5-qrcode": "2.3.8",
"i18next-browser-languagedetector": "8.2.1",
"lucide-react": "1.30.0",
"lucide-react": "1.20.0",
"next-themes": "0.4.6",
"qrcode": "1.5.4",
"radix-ui": "1.6.7",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "7.84.0",
"react-i18next": "17.0.11",
"react-router-dom": "7.18.2",
"radix-ui": "1.6.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "7.79.0",
"react-i18next": "17.0.8",
"react-router-dom": "7.18.0",
"react-use-websocket": "4.13.0",
"sonner": "2.0.7",
"tailwind-merge": "3.6.0",
"tailwindcss": "4.3.3",
"tailwindcss": "4.3.1",
"tw-animate-css": "1.4.0",
"yup": "1.7.1",
"zustand": "5.0.14"
},
"devDependencies": {
"@chromatic-com/storybook": "5.3.0",
"@eslint/js": "9.39.5",
"@playwright/test": "1.62.0",
"@storybook/addon-a11y": "10.5.7",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-vitest": "10.5.7",
"@storybook/react-vite": "10.5.7",
"@tanstack/eslint-plugin-query": "5.101.4",
"@testing-library/jest-dom": "7.0.0",
"@chromatic-com/storybook": "5.2.1",
"@eslint/js": "9.39.4",
"@playwright/test": "1.61.0",
"@storybook/addon-a11y": "10.4.6",
"@storybook/addon-docs": "10.4.6",
"@storybook/addon-vitest": "10.4.6",
"@storybook/react-vite": "10.4.6",
"@tanstack/eslint-plugin-query": "5.101.0",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@testing-library/user-event": "14.6.3",
"@testing-library/user-event": "14.6.1",
"@trivago/prettier-plugin-sort-imports": "6.0.2",
"@types/node": "26.1.2",
"@types/node": "24.12.4",
"@types/qrcode": "1.5.6",
"@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.1",
"eslint": "9.39.5",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"@vitest/browser": "4.1.9",
"@vitest/browser-playwright": "4.1.9",
"@vitest/coverage-v8": "4.1.9",
"conventional-changelog": "7.2.0",
"eslint": "9.39.4",
"eslint-plugin-compat": "7.0.2",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"eslint-plugin-storybook": "10.5.7",
"eslint-plugin-unicorn": "65.0.1",
"globals": "17.9.0",
"eslint-plugin-storybook": "10.4.6",
"eslint-plugin-unicorn": "64.0.0",
"globals": "17.6.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"lint-staged": "17.3.0",
"msw": "2.15.0",
"msw-storybook-addon": "3.0.0",
"prettier": "3.9.6",
"prettier-plugin-tailwindcss": "0.8.1",
"storybook": "10.5.7",
"jsdom": "29.1.1",
"lint-staged": "17.0.7",
"msw": "2.14.6",
"msw-storybook-addon": "2.0.7",
"prettier": "3.8.4",
"prettier-plugin-tailwindcss": "0.8.0",
"storybook": "10.4.6",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"vite": "8.2.1",
"vitest": "4.1.10"
"typescript-eslint": "8.61.1",
"vite": "8.0.16",
"vitest": "4.1.9"
},
"overrides": {
"storybook": "$storybook",
"@joinmarket-webui/joinmarket-ng-api-ts": {
"@joinmarket-webui/joinmarket-api-ts": {
"@tanstack/react-query": "$@tanstack/react-query"
}
},

View file

@ -7,8 +7,8 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.15.0'
const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e'
const PACKAGE_VERSION = '2.14.6'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@ -98,10 +98,7 @@ addEventListener('fetch', function (event) {
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
return
}
@ -124,12 +121,7 @@ addEventListener('fetch', function (event) {
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
const response = await getResponse(event, client, requestId, requestInterceptedAt)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
@ -137,18 +129,8 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get('content-type')
?.toLowerCase()
.startsWith('text/event-stream')
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone()
const responseClone = response.clone()
sendToClient(
client,
@ -161,17 +143,15 @@ async function handleRequest(event, requestId, requestInterceptedAt) {
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone && responseClone.body
? [serializedRequest.body, responseClone.body]
: [],
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
@ -236,9 +216,7 @@ async function getResponse(event, client, requestId, requestInterceptedAt) {
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
const filteredValues = values.filter((value) => value !== 'msw/passthrough')
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
@ -309,10 +287,7 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)])
})
}

View file

@ -58,7 +58,7 @@ const generateChangelog = (newVersion) => {
})
changelogStream.on('end', () => {
changelog = changelog.replaceAll(/###\s(\w+)/g, '#### $1').replaceAll('\n'.repeat(3), '\n\n')
changelog = changelog.replaceAll(/###\s(\w+)/g, '#### $1').replaceAll('\n\n\n', '\n\n')
const finalChangelog = header + '\n' + (changelog + oldContent).replaceAll(/\n+$/, '\n')
fs.writeFileSync(file, finalChangelog, 'utf8')
return resolve()

View file

@ -67,10 +67,6 @@ vi.mock('./store/jmSessionStore', () => ({
jmSessionStore: { getState: () => ({ state: holders.jmSession }) },
}))
vi.mock('./store/jmTxStore', () => ({
jmTxStore: { getState: () => ({ state: {} }) },
}))
vi.mock('@/store/jamSettingsStore', () => ({
jamSettingsStore: { getState: () => ({ state: { developerMode: holders.developerMode } }) },
useDeveloperMode: () => ({ enabled: holders.developerMode }),
@ -86,11 +82,11 @@ vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({ mutateAsync }),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
lockwalletOptions: () => ({ queryKey: ['lock'] }),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
token: vi.fn().mockResolvedValue({ data: { token: 't', refresh_token: 'r' } }),
}))

View file

@ -1,7 +1,7 @@
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import type { PropsWithChildren } from 'react'
import { lockwalletOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { token } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { lockwalletOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import { token } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { QueryClientProvider, useMutation, useQuery } from '@tanstack/react-query'
import type { TFunction } from 'i18next'
import { ThemeProvider } from 'next-themes'
@ -45,10 +45,8 @@ import { setIntervalDebounced, walletDisplayName, type WalletFileName } from '@/
import { authStore } from '@/store/authStore'
import { jamSettingsStore, useDeveloperMode } from '@/store/jamSettingsStore'
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'
@ -56,7 +54,6 @@ import { useJamWalletInfoContext } from './context/JamWalletInfoContext'
import { JmWebsocketContextProvider } from './context/JmWebsocketContextProvider'
import { getErrorReason } from './lib/errorReason'
import { jmSessionStore } from './store/jmSessionStore'
import { jmTxStore, type JmTxInfos } from './store/jmTxStore'
import type { Milliseconds } from './types/global'
const DevSetupPage = lazy(() => import('@/components/dev/DevSetupPage'))
@ -156,15 +153,7 @@ function App() {
const router = createBrowserRouter(
createRoutesFromElements(
<Route
id="base"
element={
<RootLayout>
<Outlet />
</RootLayout>
}
errorElement={<ErrorPage />}
>
<Route id="base" element={<Outlet />} errorElement={<ErrorPage />}>
<Route path={routes.login} element={authenticated ? <Navigate to={routes.home} replace /> : <LoginPage />} />
<Route
path={routes.createWallet}
@ -233,15 +222,11 @@ function App() {
/>
<Route path={routes.orderbook} element={<OrderbookPage />} />
<Route path={routes.logs} element={<LogsPage />} />
<Route
path={routes.rescan}
element={<RescanChainPage walletFileName={walletFileName!} backLinkTarget="settings" />}
/>
<Route path={routes.rescan} element={<RescanChainPage walletFileName={walletFileName!} />} />
<Route
path={routes.walletJarsDetails}
element={<WalletJarsDetailsPage walletFileName={walletFileName!} />}
/>
<Route path={routes.txHistory} element={<TxHistoryPage walletFileName={walletFileName!} />} />
{isDeveloperMode && isDebugFeatureEnabled('devPage') && (
<Route
id="dev-page"
@ -321,7 +306,7 @@ function RefreshApiToken() {
clearAuthAndQueryCache()
if (isDevMode) {
const message = getErrorReason(response.error, 'Unknown error.')
const message = response.error?.message || response.error?.error_description || 'Unknown error.'
toast.error(`[DEV] Error while renewing auth token: ${message}`, {
id: 'token-renew-error',
})
@ -379,7 +364,8 @@ function LoadFeeConfigData({ walletFileName }: { walletFileName: WalletFileName
})
.catch((error) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
const errorMessage = t('settings.fees.error_loading_fee_config_failed', { reason })
// TODO: i18n
const errorMessage = t('Error while loading fee values.: {{ reason }}', { reason })
toast.error(errorMessage)
console.error(errorMessage)
})
@ -393,7 +379,6 @@ const RELOAD_WALLET_INFO_DELAY: {
AFTER_UTXO_CHANGE: Milliseconds
AFTER_BLOCK_HEIGHT_CHANGE: Milliseconds
AFTER_TAKER_STOPPED: Milliseconds
AFTER_TX_MESSAGE: Milliseconds
} = {
// After rescanning, it is necessary to give the JM backend some time to synchronize.
// A couple of seconds should be enough, however, this depends on the user hardware
@ -411,9 +396,6 @@ const RELOAD_WALLET_INFO_DELAY: {
// Small delay is sufficient after block height change
AFTER_TAKER_STOPPED: 210,
// Small delay is sufficient after an incoming tx message via websocket
AFTER_TX_MESSAGE: 210,
}
/**
@ -435,9 +417,6 @@ const WalletInfoAutoReload = () => {
const previousBlockHeightRef = useRef<number | undefined>(currentBlockHeight)
const previousTakerRunningRef = useRef<boolean>(currentTakerRunning)
const jmTxs = useStore(jmTxStore, (state) => state.state)
const previousJmTxsRef = useRef<JmTxInfos>(jmTxs)
const { refetch: refetchWalletBalance, utxosHashHex } = useJamWalletInfoContext()
useEffect(
@ -507,26 +486,6 @@ const WalletInfoAutoReload = () => {
[refetchWalletBalance, utxosHashHex],
)
useEffect(
function refetchWalletInfoAfterTxMessage() {
const txsChanged = previousJmTxsRef.current !== jmTxs
previousJmTxsRef.current = jmTxs
if (!txsChanged) {
return
}
const delayBefore = RELOAD_WALLET_INFO_DELAY.AFTER_TX_MESSAGE
console.debug('Trigger refetch looking for funds AFTER_TX_MESSAGE with delay %d...', delayBefore)
const abortCtrl = new AbortController()
refetchWalletBalance({ delayBefore, signal: abortCtrl.signal }).catch((error: unknown) => {
console.warn('Error while auto-reloading wallet info AFTER_TX_MESSAGE finished', error)
})
return () => abortCtrl.abort('useEffect(AFTER_TX_MESSAGE) ended')
},
[refetchWalletBalance, jmTxs],
)
useEffect(
function refetchWalletInfoAfterTakerStopped() {
const takerStopped = previousTakerRunningRef.current === true && currentTakerRunning === false

View file

@ -11,7 +11,7 @@ export function LogsOverlay({ open, onOpenChange }: LogsOverlayProps) {
const { t } = useTranslation()
return (
<Dialog open={open} onOpenChange={() => onOpenChange(false)}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PageTitle title={t('logs.title')} />

View file

@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { WalletFileName } from '@/lib/utils'
import type { JarIndex } from '@/types/global'
import MainWalletPage from './MainWalletPage'
const navigateMock = vi.fn()
@ -32,38 +32,23 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => ({ jars }),
}))
vi.mock('@/store/jamSettingsStore', () => ({
usePreviewFeatures: () => ({
'tx-history': true,
}),
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
shortenStringMiddle: (s: string) => s,
walletDisplayName: (s: string) => s,
}))
vi.mock('@/components/ui/jam/ClickableJar', () => ({
ClickableJar: ({ name, onClick }: { name: string; onClick: () => void }) => <button onClick={onClick}>{name}</button>,
}))
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
WalletJarsDetailsOverlay: ({
open,
onOpenChange,
selectedJarIndex,
}: {
open: boolean
onOpenChange?: (open: boolean) => void
selectedJarIndex?: JarIndex
}) => (
<div data-testid="WalletJarsDetailsOverlay">
{open ? 'open' : 'closed'}
<span data-testid="WalletJarsDetailsOverlay#open">{open ? 'open' : 'closed'}</span>
<span data-testid="WalletJarsDetailsOverlay#selectedJarIndex">{selectedJarIndex ?? 'undefined'}</span>
<button data-testid="WalletJarsDetailsOverlay#onOpenChange" onClick={() => onOpenChange?.(!open)}>
Close
</button>
</div>
WalletJarsDetailsOverlay: ({ open }: { open: boolean }) => (
<div data-testid="overlay">{open ? 'open' : 'closed'}</div>
),
}))
vi.mock('./wallet/TxHistoryContent', () => ({
TxHistoryContent: () => <div data-testid="TxHistoryContent" />,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
vi.mock('./ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>{valueString}</span>
),
@ -73,6 +58,23 @@ vi.mock('./ui/spinner', () => ({
Spinner: () => <div data-testid="spinner" />,
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
const walletFileName = 'wallet.jmdat' as WalletFileName
const makeJar = (jarIndex: number, name: string): Jar =>
@ -84,7 +86,6 @@ const makeJar = (jarIndex: number, name: string): Jar =>
calculatedTotalBalanceInSats: 100,
calculatedAvailableBalanceInSats: 100,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
},
}) as unknown as Jar
@ -104,20 +105,10 @@ describe('MainWalletPage', () => {
it('renders balance and jars, and opens the jar overlay on click', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByText('5000')).toBeInTheDocument()
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('closed')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
expect(screen.getByTestId('overlay')).toHaveTextContent('closed')
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.getByTestId('WalletJarsDetailsOverlay#onOpenChange'))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('closed')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
fireEvent.click(screen.getByTestId('WalletJarsDetailsOverlay#onOpenChange'))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
fireEvent.click(screen.getByText('Jar 0'))
expect(screen.getByTestId('overlay')).toHaveTextContent('open')
})
it('navigates to deposit and withdraw routes', () => {
@ -135,14 +126,10 @@ describe('MainWalletPage', () => {
expect(refetch).toHaveBeenCalled()
})
it('keeps jars visible and clickable while fetching in the background', () => {
it('shows a jars spinner while fetching and refreshes on click', () => {
walletInfo = { ...walletInfo, isFetching: true }
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByText(jars[0].name)).toBeInTheDocument()
fireEvent.click(screen.getByText(jars[0].name))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent(String(jars[0].jarIndex))
expect(screen.getByTestId('spinner')).toBeInTheDocument()
fireEvent.click(screen.getByText('global.refresh'))
expect(refetch).toHaveBeenCalled()
})
@ -152,17 +139,4 @@ describe('MainWalletPage', () => {
fireEvent.click(screen.getByText('5000'))
expect(toggleDisplayMode).toHaveBeenCalled()
})
it('expands and collapses transaction history via accordion toggle button', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
const toggleButton = screen.getByTitle('tx_history.expand_history')
fireEvent.click(toggleButton)
expect(screen.getByTestId('TxHistoryContent')).toBeInTheDocument()
const collapseButton = screen.getByTitle('tx_history.collapse_history')
fireEvent.click(collapseButton)
expect(screen.queryByTestId('TxHistoryContent')).not.toBeInTheDocument()
})
})

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronDownIcon, DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
import { DownloadIcon, InfoIcon, RefreshCwIcon, UploadIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { Alert, AlertDescription } from '@/components/ui/alert'
@ -14,14 +14,10 @@ import {
useWalletBalanceSummary,
type Jar as JarObject,
} from '@/context/JamWalletInfoContext'
import { getErrorReason } from '@/lib/errorReason'
import type { WalletFileName } from '@/lib/utils'
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
import { usePreviewFeatures } from '@/store/jamSettingsStore'
import { Balance } from './ui/jam/Balance'
import { Spinner } from './ui/spinner'
import { TxHistoryContent } from './wallet/TxHistoryContent'
import { TxHistoryOverlay } from './wallet/TxHistoryOverlay'
import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay'
interface MainWalletPageProps {
@ -31,11 +27,8 @@ interface MainWalletPageProps {
export default function MainWalletPage({ walletFileName }: MainWalletPageProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const previewFeatures = usePreviewFeatures()
const [selectedJar, setSelectedJar] = useState<JarObject>()
const [isWalletJarsDetailsOpen, setIsWalletJarsDetailsOpen] = useState(false)
const [isTxHistoryOpen, setIsTxHistoryOpen] = useState(false)
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false)
const { toggleDisplayMode } = useJamDisplayContext()
const { isLoading, isFetching, error, refetch: refetchWalletData } = useJamWalletInfoContext()
@ -61,10 +54,6 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
walletFileName={walletFileName}
selectedJarIndex={selectedJar?.jarIndex}
/>
{previewFeatures?.['tx-history'] === true ? (
<TxHistoryOverlay open={isTxHistoryOpen} onOpenChange={setIsTxHistoryOpen} walletFileName={walletFileName} />
) : null}
<div className="flex flex-col items-center justify-center gap-8 px-4 py-12">
<div className="flex w-full max-w-xl flex-col items-center justify-center gap-2">
<p className="text-muted-foreground hover:text-foreground text-xl select-all" title={walletName}>
@ -78,7 +67,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('global.loading')}
</div>
) : (
<div className="flex items-center text-3xl tracking-wider sm:text-4xl">
<div className="flex items-center text-4xl tracking-wider">
<Balance
onClick={() => toggleDisplayMode()}
valueString={String(walletBalanceSummary.calculatedTotalBalanceInSats)}
@ -87,7 +76,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</div>
)}
</div>
<div className="mt-2 flex w-full justify-center gap-2 sm:gap-4" data-tour-id="wallet-actions">
<div className="mt-2 flex w-full justify-center gap-4" data-tour-id="wallet-actions">
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
<DownloadIcon />
{t('current_wallet.button_deposit')}
@ -97,50 +86,21 @@ 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 ? (
{error && (
<Alert variant="destructive" className="mb-4 max-w-xl">
<AlertDescription>
{t('global.errors.error_loading_wallet_failed', {
reason: getErrorReason(error, t('global.errors.reason_unknown')),
reason: error.message || t('global.errors.reason_unknown'),
})}
<Button variant="outline" onClick={() => void refetchWalletData()}>
<Button variant="outline" size="sm" onClick={() => void refetchWalletData()}>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
{t('global.retry')}
</Button>
</AlertDescription>
</Alert>
) : null}
)}
<div className="light:text-black mt-8 mb-4 flex w-full flex-col gap-8 text-white" data-tour-id="wallet-jars">
<div className="text-muted-foreground hover:text-foreground">
@ -155,7 +115,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</Tooltip>
</div>
<div className="flex min-h-[128px] items-center justify-center gap-4">
{isLoading ? (
{isFetching ? (
<div className="flex flex-1 items-center justify-center gap-2 py-8">
<Spinner className="motion-reduce:hidden" />
{t('global.loading')}
@ -186,7 +146,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</div>
</div>
<div className="flex w-full max-w-xl justify-end">
<Button variant="ghost" onClick={() => void refetchWalletData()}>
<Button variant="ghost" size="xs" onClick={() => void refetchWalletData()}>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
{t('global.refresh')}
</Button>

View file

@ -29,7 +29,13 @@ const createFormSchema = (t: TFunction) => {
return yup
.object({
revealSensitiveInfo: yup.boolean().required(),
backupConfirmed: yup.boolean().isTrue(t('create_wallet.subtitle_wallet_created')),
backupConfirmed: yup
.boolean()
.isTrue(
/* TODO: i18n */ t(
'Please write down your seed phrase and password! Without this information you will not be able to access and recover your wallet!',
),
),
})
.required()
}
@ -71,10 +77,10 @@ export const CreateStepConfirm = ({
useEffect(() => {
if (backupConfirmed) return
const toastId = toast.info(t('create_wallet.alert_save_seed_title'), {
const toastId = toast.info(/* TODO: i18n */ 'Save Your Seed Phrase', {
id: 'save-seed-phrase-reminder',
icon: <AlertCircleIcon />,
description: t('create_wallet.subtitle_wallet_created'),
description: /* TODO: change i18n key ("alert_description") */ t('create_wallet.subtitle_wallet_created'),
duration: Number.POSITIVE_INFINITY,
})
@ -112,22 +118,15 @@ export const CreateStepConfirm = ({
<div className="space-y-4">
<div className="space-y-2">
<Field
data-invalid={errors.revealSensitiveInfo !== undefined}
orientation="horizontal"
className="items-start"
>
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
<Switch
className="mt-0.5 shrink-0"
id="switch-reveal-seed"
checked={revealSensitiveInfo}
onCheckedChange={(checked) =>
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
}
/>
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
{t('create_wallet.confirmation_toggle_reveal_info')}
</FieldLabel>
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
</Field>
{errors.revealSensitiveInfo?.message && (
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>
@ -135,16 +134,15 @@ export const CreateStepConfirm = ({
</div>
<div className="space-y-2">
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal" className="items-start">
<Field data-invalid={errors.backupConfirmed !== undefined} orientation="horizontal">
<Switch
className="mt-0.5 shrink-0"
id="switch-confirm-backup"
required={true}
disabled={!touchedFields.revealSensitiveInfo}
checked={backupConfirmed}
onCheckedChange={(checked) => setValue('backupConfirmed', checked, { shouldValidate: true })}
/>
<FieldLabel htmlFor="switch-confirm-backup" className="min-w-0 flex-1">
<FieldLabel htmlFor="switch-confirm-backup">
{t('create_wallet.confirmation_toggle_info_written_down')}
</FieldLabel>
</Field>

View file

@ -87,7 +87,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
<div className="bg-muted h-1.5 w-full overflow-hidden rounded-full">
<div
className={cn('h-full rounded-full transition-all duration-300 ease-out', {
'bg-brand-success/40': isCorrect,
'bg-green-300/50': isCorrect,
'bg-primary': !isCorrect,
})}
style={{ width: `${(progress / total) * 100}%` }}
@ -100,10 +100,10 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
className={cn('rounded-lg border-2 border-dashed p-2.5 transition-colors', {
'border-destructive/50': wrongButtonIndex !== undefined,
'border-muted-foreground/20': wrongButtonIndex === undefined,
'border-brand-success/50 bg-brand-success/10': allSelected && isCorrect,
'border-green-300/50 bg-green-600/5': allSelected && isCorrect,
})}
>
<div className="grid grid-cols-2 gap-1.5 select-none sm:grid-cols-3">
<div className="grid grid-cols-3 gap-1.5 select-none">
{!isCorrect &&
mnemonicPhrase.map((_, index) => {
const word = selectedWords[index]
@ -112,13 +112,10 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
return (
<div
key={index}
className={cn(
'flex min-w-0 items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all',
{
'bg-primary/10 text-primary border-primary/20 border': isFilled,
'border-muted bg-muted/30 border border-dashed': !isFilled,
},
)}
className={cn('flex items-center gap-0.5 rounded-md px-0.5 py-1.5 font-mono text-xs transition-all', {
'bg-primary/10 text-primary border-primary/20 border': isFilled,
'border-muted bg-muted/30 border border-dashed': !isFilled,
})}
>
<span
className={cn('min-w-8 text-right tabular-nums', {
@ -129,7 +126,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
{index + 1}.
</span>
{isFilled ? (
<span className="min-w-0">{isHidden ? <MaskedText masked /> : word}</span>
<span>{isHidden ? <MaskedText masked /> : word}</span>
) : (
<span className="text-muted-foreground/30">···</span>
)}
@ -138,7 +135,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
})}
</div>
{allSelected && isCorrect && (
<div className="text-brand-success flex h-full items-center justify-center gap-1.5 text-sm font-medium">
<div className="flex h-full items-center justify-center gap-1.5 text-sm font-medium text-green-300">
<CheckCircle2Icon className="size-4" />
{t('create_wallet.verify_mnemonic.feedback_mnemonic_confirmed')}
</div>
@ -146,7 +143,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
</div>
{!allSelected && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div className="grid grid-cols-3 gap-2">
{shuffledWords.map((word, index) => {
const isPicked = pickedIndicesSet.has(index)
const isWrong = wrongButtonIndex === index
@ -157,7 +154,7 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
size="lg"
variant={isWrong ? 'destructive' : isPicked ? 'outline' : 'secondary'}
disabled={isPicked || wrongButtonIndex !== undefined}
className={cn('min-w-0 px-2 font-mono text-sm transition-all', {
className={cn('font-mono text-sm transition-all', {
'pointer-events-none opacity-25': isPicked,
'animate-shake': isWrong,
})}
@ -170,21 +167,24 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
</div>
)}
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
type="button"
className="w-full"
variant="outline"
className={cn('flex-1', {
hidden: isCorrect,
})}
size="xxl"
disabled={!isCorrect || verifyMutation.isPending}
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
onClick={onBack}
disabled={isCorrect || verifyMutation.isPending}
>
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
{t('create_wallet.confirmation_button_fund_wallet')}
<ChevronLeftIcon />
{t('create_wallet.back_button')}
</Button>
{skipWalletBackupVerification && !isCorrect && (
<Button
type="button"
className="w-full"
className="flex-1"
variant="secondary"
size="xxl"
disabled={verifyMutation.isPending}
@ -195,15 +195,13 @@ export const CreateStepVerifyMnemonic = ({ mnemonicPhrase, onVerified, onBack }:
)}
<Button
type="button"
variant="ghost"
className={cn({
hidden: isCorrect,
})}
onClick={onBack}
disabled={isCorrect || verifyMutation.isPending}
className="flex-1"
size="xxl"
disabled={!isCorrect || verifyMutation.isPending}
onClick={() => verifyMutation.mutate({ mustBeCorrect: true })}
>
<ChevronLeftIcon />
{t('create_wallet.back_button')}
{verifyMutation.isPending && <Spinner className="motion-reduce:hidden" />}
{t('create_wallet.confirmation_button_fund_wallet')}
</Button>
</div>
</div>

View file

@ -1,6 +1,5 @@
import type { ComponentProps } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useTranslation } from 'react-i18next'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { routes } from '@/constants/routes'
@ -13,24 +12,20 @@ type CreateStepWalletDetailsProps = ComponentProps<typeof CreateWalletForm> & {
}
export const CreateStepWalletDetails = ({ sessionInfo, ...createFormProps }: CreateStepWalletDetailsProps) => {
const { t } = useTranslation()
return (
<div className="space-y-2">
{sessionInfo?.session === true ? (
<OtherWalletActiveAlert
linkTarget={'login'}
walletFileName={sessionInfo.wallet_name as WalletFileName | undefined}
/>
<OtherWalletActiveAlert linkTarget={'login'} walletFileName={sessionInfo.wallet_name as WalletFileName} />
) : (
<CreateWalletForm {...createFormProps} />
)}
<div className="text-center">
<p className="text-muted-foreground text-sm">
{t('create_wallet.text_login_hint')}{' '}
{/* TODO: i18n */}
Already have a wallet?{' '}
<Button variant="link" asChild>
<Link to={routes.login} className="font-semibold">
{t('create_wallet.button_login_link')}
Sign in here
</Link>
</Button>
</p>

View file

@ -6,7 +6,7 @@ import { useForm, type Mode, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
import { Field, FieldError, FieldLabel } from '@/components/ui/field'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Spinner } from '@/components/ui/spinner'
@ -91,81 +91,89 @@ export const CreateWalletForm = ({
return (
<form onSubmit={(event) => void doOnSubmit(event)} className={cn('flex flex-col gap-4', className)} noValidate>
<Field data-invalid={errors.walletName !== undefined}>
<FieldLabel htmlFor="create-wallet-name">{t('create_wallet.label_wallet_name')}</FieldLabel>
<Input
id="create-wallet-name"
{...register('walletName', {
required: true,
disabled,
})}
placeholder={t('create_wallet.placeholder_wallet_name')}
autoComplete="off"
/>
{errors.walletName?.message && <FieldError>{errors.walletName.message}</FieldError>}
</Field>
<Field data-invalid={errors.password !== undefined}>
<FieldLabel htmlFor="create-password">{t('create_wallet.label_password')}</FieldLabel>
<InputGroup>
<InputGroupInput
id="create-password"
{...register('password', {
<div className="space-y-2">
<Field data-invalid={errors.walletName !== undefined}>
<FieldLabel htmlFor="create-wallet-name">{t('create_wallet.label_wallet_name')}</FieldLabel>
<Input
id="create-wallet-name"
{...register('walletName', {
required: true,
disabled,
})}
type={showPassword ? 'text' : 'password'}
placeholder={t('create_wallet.placeholder_password')}
placeholder={t('create_wallet.placeholder_wallet_name')}
autoComplete="off"
/>
<InputGroupAddon align="inline-start">
<LockIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
tabIndex={-1}
type="button"
variant="link"
size="icon"
onClick={() => setShowPassword((val) => !val)}
>
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
</Button>
</InputGroupAddon>
</InputGroup>
{errors.password?.message && <FieldError>{errors.password.message}</FieldError>}
</Field>
</Field>
{errors.walletName?.message && <div className="text-destructive text-xs">{errors.walletName.message}</div>}
</div>
<Field data-invalid={errors.confirmPassword !== undefined}>
<FieldLabel htmlFor="create-confirm-password">{t('create_wallet.label_password_confirm')}</FieldLabel>
<InputGroup>
<InputGroupInput
id="create-confirm-password"
{...register('confirmPassword', {
required: true,
disabled,
})}
type={showConfirmPassword ? 'text' : 'password'}
placeholder={t('create_wallet.placeholder_password_confirm')}
autoComplete="off"
/>
<InputGroupAddon align="inline-start">
<LockIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
tabIndex={-1}
type="button"
variant="link"
size="icon"
onClick={() => setShowConfirmPassword((val) => !val)}
>
{showConfirmPassword ? <EyeIcon /> : <EyeOffIcon />}
</Button>
</InputGroupAddon>
</InputGroup>
{errors.confirmPassword?.message && <FieldError>{errors.confirmPassword?.message}</FieldError>}
</Field>
<div className="space-y-2">
<Field data-invalid={errors.password !== undefined}>
<FieldLabel htmlFor="create-password">{t('create_wallet.label_password')}</FieldLabel>
<InputGroup>
<InputGroupInput
id="create-password"
{...register('password', {
required: true,
disabled,
})}
type={showPassword ? 'text' : 'password'}
placeholder={t('create_wallet.placeholder_password')}
autoComplete="off"
/>
<InputGroupAddon align="inline-start">
<LockIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
tabIndex={-1}
type="button"
variant="link"
size="icon"
onClick={() => setShowPassword((val) => !val)}
>
{showPassword ? <EyeIcon /> : <EyeOffIcon />}
</Button>
</InputGroupAddon>
</InputGroup>
</Field>
{errors.password?.message && <div className="text-destructive text-xs">{errors.password.message}</div>}
</div>
<div className="space-y-2">
<Field data-invalid={errors.confirmPassword !== undefined}>
<FieldLabel htmlFor="create-confirm-password">{t('create_wallet.label_password_confirm')}</FieldLabel>
<InputGroup>
<InputGroupInput
id="create-confirm-password"
{...register('confirmPassword', {
required: true,
disabled,
})}
type={showConfirmPassword ? 'text' : 'password'}
placeholder={t('create_wallet.placeholder_password_confirm')}
autoComplete="off"
/>
<InputGroupAddon align="inline-start">
<LockIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<Button
tabIndex={-1}
type="button"
variant="link"
size="icon"
onClick={() => setShowConfirmPassword((val) => !val)}
>
{showConfirmPassword ? <EyeIcon /> : <EyeOffIcon />}
</Button>
</InputGroupAddon>
</InputGroup>
</Field>
{errors.confirmPassword?.message && (
<div className="text-destructive text-xs">{errors.confirmPassword?.message}</div>
)}
</div>
<Button type="submit" className="w-full" disabled={disabled || isSubmitting} size="xxl">
{isSubmitting && <Spinner className="motion-reduce:hidden" />}

View file

@ -21,14 +21,14 @@ const mocks = vi.hoisted(() => ({
type QueryOptions = { queryKey?: readonly unknown[] }
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
createwalletMutation: vi.fn(() => ({ mutationFn: mocks.createWallet })),
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
sessionOptions: vi.fn(() => ({ queryKey: ['session'], queryFn: vi.fn() })),
unlockwalletMutation: vi.fn(() => ({ mutationFn: mocks.unlockWallet })),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
lockwallet: mocks.lockWallet,
}))

View file

@ -5,11 +5,11 @@ import {
listwalletsOptions,
sessionOptions,
unlockwalletMutation,
} from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { lockwallet } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import { lockwallet } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { CircleCheckBigIcon, ShieldCheckIcon, WalletIcon, type LucideIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { useStore } from 'zustand'
@ -41,7 +41,7 @@ const CreateWalletCard = ({ icon: Icon, title, children }: PropsWithChildren<{ i
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Icon className="text-primary" />
</div>
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
<CardTitle className="text-xl font-bold">{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">{children}</CardContent>
</Card>
@ -112,19 +112,20 @@ const CreateWalletPage = () => {
const { data: sessionInfo } = await sessionQuery.refetch()
if (sessionInfo?.session === true) {
console.warn('Active session detected:', sessionInfo)
// TODO: i18n
toast.error(
t('create_wallet.error_wallet_active', {
walletName: walletDisplayName((sessionInfo?.wallet_name || 'Unknown') as WalletFileName),
}),
`Cannot create wallet as "${walletDisplayName(
(sessionInfo?.wallet_name || 'Unknown') as WalletFileName,
)}" wallet is currently active.`,
{
description: (
<Trans i18nKey="create_wallet.error_wallet_active_hint">
<>
Alternatively, you can{' '}
<a href={routes.login} className="font-medium underline hover:no-underline">
log in with the existing wallet
</a>{' '}
instead.
</Trans>
</>
),
duration: 10_000,
},
@ -180,7 +181,8 @@ const CreateWalletPage = () => {
setStep('confirm')
} catch (error: unknown) {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
toast.error(t('create_wallet.error_creating_wallet', { reason }))
/* TODO: i18n */
toast.error(`Failed to create wallet: ${reason}`)
} finally {
toast.dismiss(durationHintToastId)
}
@ -209,7 +211,8 @@ const CreateWalletPage = () => {
await navigate(routes.home)
} catch (error: unknown) {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
toast.error(t('login.error_unlocking_wallet', { reason }))
/* TODO: i18n */
toast.error(`Failed to unlock wallet: ${reason}`)
await navigate(routes.login)
}

View file

@ -16,26 +16,17 @@ export const OtherWalletActiveAlert = ({ walletFileName, linkTarget }: OtherWall
<Alert variant="warning">
<AlertCircleIcon />
<AlertDescription>
{walletFileName ? (
<Trans
i18nKey="create_wallet.alert_other_wallet_unlocked"
values={{
walletName: walletDisplayName(walletFileName),
}}
>
Currently <strong>walletName</strong> is active. You need to lock it first.
<Link to={routes[linkTarget]} className="font-semibold underline">
Go back
</Link>
</Trans>
) : (
<Trans i18nKey="create_wallet.alert_other_wallet_unlocked_generic">
Another wallet is active. You need to lock it first.
<Link to={routes[linkTarget]} className="font-semibold underline">
Go back
</Link>
</Trans>
)}
<Trans
i18nKey="create_wallet.alert_other_wallet_unlocked"
values={{
walletName: walletDisplayName((walletFileName || 'Unknown') as WalletFileName),
}}
>
Currently <strong>walletName</strong> is active. You need to lock it first.
<Link to={routes[linkTarget]} className="font-semibold underline">
Go back
</Link>
</Trans>
</AlertDescription>
</Alert>
)

View file

@ -43,8 +43,8 @@ export default function DevSetupPage() {
<h5 className="text-xl font-bold">Jam Instances</h5>
<div className="flex flex-col gap-2">
<div>
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<span className="font-mono">jm_regtest_joinmarket</span>
<Badge>primary · ng native</Badge>
</div>
@ -57,14 +57,9 @@ export default function DevSetupPage() {
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<a
href={LINK_JM_REGTEST_JOINMARKET2}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="underline">
jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
</a>
<Badge>secondary · ng standalone</Badge>
@ -77,8 +72,8 @@ export default function DevSetupPage() {
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<span className="font-mono">jm_regtest_joinmarket3</span>
<Badge>tertiary · clientserver native</Badge>
</div>
@ -90,14 +85,9 @@ export default function DevSetupPage() {
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<WalletIcon className="h-4 w-4 shrink-0" />
<a
href={LINK_JM_REGTEST_JOINMARKET4}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
<div className="flex items-center gap-2">
<WalletIcon className="h-4 w-4" />
<a href={LINK_JM_REGTEST_JOINMARKET4} target="_blank" rel="noopener noreferrer" className="underline">
jm_regtest_joinmarket4 ({LINK_JM_REGTEST_JOINMARKET4})
</a>
<Badge>quaternary · clientserver standalone</Badge>
@ -125,15 +115,10 @@ export default function DevSetupPage() {
<h5 className="text-xl font-bold">Block Explorer</h5>
<div>
{' '}
<div className="flex flex-wrap items-center gap-2">
<BlocksIcon className="h-4 w-4 shrink-0" />
<div className="flex items-center gap-2">
<BlocksIcon className="h-4 w-4" />
<a
href={LINK_JM_REGTEST_EXPLORER}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
<a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="underline">
jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
</a>
</div>
@ -148,23 +133,16 @@ export default function DevSetupPage() {
</div>
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<TerminalIcon className="h-4 w-4 shrink-0" />
<div className="flex items-center gap-2">
<TerminalIcon className="h-4 w-4" />
<a
href={LINK_JM_REGTEST_RPC_TERMINAL}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-all underline"
>
<a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="underline">
Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
</a>
</div>
<div className="my-2 ms-5">
Mine a block, e.g.:
<pre className="max-w-full overflow-x-auto">
generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx
</pre>
<pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
</div>
</div>
</div>

View file

@ -31,7 +31,7 @@ vi.mock('./CreateFidelityBondDialogSteps', () => ({
CreateFidelityBondDialogSteps: () => <div data-testid="steps-component">Steps Component</div>,
}))
vi.mock('../fidelity-bond/StepProgress', () => ({
vi.mock('./StepProgress', () => ({
StepProgress: ({ currentStep }: { currentStep: number }) => <div data-testid="step-progress">{currentStep}</div>,
}))
@ -45,8 +45,8 @@ describe('CreateFidelityBondDialog', () => {
// Check buttons
expect(screen.queryByText('global.back')).not.toBeInTheDocument()
expect(screen.getByText('global.cancel')).toBeInTheDocument()
expect(screen.getByText('global.next')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.text_secondary_button')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.text_primary_button')).toBeInTheDocument()
})
it('renders correctly on review step', () => {
@ -65,7 +65,7 @@ describe('CreateFidelityBondDialog', () => {
render(<CreateFidelityBondDialog open={true} onOpenChange={vi.fn()} walletFileName="wallet.jmdat" />)
expect(screen.queryByTestId('step-progress')).not.toBeInTheDocument()
expect(screen.getByText('global.done')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.text_primary_button')).toBeInTheDocument()
})
it('renders correctly on freeze_utxos step', () => {

View file

@ -1,13 +1,20 @@
import { AlertTriangleIcon, ChevronLeftIcon } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/spinner'
import { FidelityBondDialogLayout } from '../fidelity-bond/FidelityBondDialogLayout'
import { WizardStepFooter } from '../fidelity-bond/WizardStepFooter'
import { CreateFidelityBondDialogSteps } from './CreateFidelityBondDialogSteps'
import { StepProgress } from './StepProgress'
import type { CreateFidelityBondDialogProps } from './types'
import { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
const TOTAL_STEPS = 5
export function CreateFidelityBondDialog({
open,
onOpenChange,
@ -38,9 +45,14 @@ export function CreateFidelityBondDialog({
if (step === 'success') {
return (
<>
<DialogFooter className="gap-3 sm:gap-2">
{frozenUtxos.length > 0 && (
<Button variant="outline" onClick={() => void handleUnfreezeUtxos()} disabled={unfreezeUtxo.isPending}>
<Button
variant="outline"
className="min-w-32"
onClick={() => void handleUnfreezeUtxos()}
disabled={unfreezeUtxo.isPending}
>
{unfreezeUtxo.isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
@ -51,43 +63,63 @@ export function CreateFidelityBondDialog({
)}
</Button>
)}
<Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
</>
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.create_fidelity_bond.text_primary_button')}
</Button>
</DialogFooter>
)
}
const isLoading = freezeUtxo.isPending || directSend.isPending
return (
<WizardStepFooter
onBack={step !== 'select_date' ? handleBack : undefined}
onCancel={() => handleOpenChange(false)}
onPrimary={() => void handleNext()}
primaryDisabled={!canProceed()}
isLoading={freezeUtxo.isPending || directSend.isPending}
primaryLabel={
step === 'freeze_utxos'
<DialogFooter className="gap-3 sm:gap-2">
{step !== 'select_date' && (
<Button variant="ghost" onClick={handleBack} disabled={isLoading}>
<ChevronLeftIcon />
{t('global.back')}
</Button>
)}
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
{t('earn.fidelity_bond.select_date.text_secondary_button')}
</Button>
<Button className="min-w-32" onClick={() => void handleNext()} disabled={!canProceed() || isLoading}>
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{step === 'freeze_utxos'
? utxosToFreeze.length > 0
? t('earn.fidelity_bond.freeze_utxos.text_primary_button')
: t('earn.fidelity_bond.freeze_utxos.text_primary_button_all_frozen')
: step === 'review'
? t('earn.fidelity_bond.review_inputs.text_primary_button')
: t('global.next')
}
/>
: t('earn.fidelity_bond.select_date.text_primary_button')}
</Button>
</DialogFooter>
)
}
return (
<FidelityBondDialogLayout
open={open}
onOpenChange={handleOpenChange}
title={t('earn.fidelity_bond.create_fidelity_bond.title')}
currentStep={step !== 'creating' && step !== 'success' ? getStepNumber() : undefined}
totalSteps={TOTAL_STEPS}
error={error}
footer={renderFooter()}
{...dialogProps}
>
<CreateFidelityBondDialogSteps wizard={wizard} />
</FidelityBondDialogLayout>
<Dialog open={open} onOpenChange={handleOpenChange} {...dialogProps}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.create_fidelity_bond.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
</DialogHeader>
{step !== 'creating' && step !== 'success' && <StepProgress currentStep={getStepNumber()} totalSteps={5} />}
{error && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="py-2">
<CreateFidelityBondDialogSteps wizard={wizard} />
</div>
{renderFooter()}
</DialogContent>
</Dialog>
)
}

View file

@ -13,59 +13,20 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock('@/components/ui/jam/BitcoinQrCode', () => ({
BitcoinAddressQrCode: () => <div data-testid="qr-code">QR</div>,
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
formatSats: (sats: number) => `${sats} sats`,
clamp: (val: number, min: number, max: number) => Math.min(Math.max(val, min), max),
}))
vi.mock('@/components/ui/jam/Address', () => ({
Address: ({ value }: { value: string }) => <span>{value}</span>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
}))
vi.mock('@/lib/fidelityBondUtils', () => ({
utxo: {
isFidelityBond: vi.fn(),
},
lockdate: {
toDateLabel: () => 'January 1, 2025',
},
}))
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: false }),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => ({
jars: [
{ jarIndex: 0, name: 'Jar 0', color: '#000', balanceSummary: {}, utxos: [] },
{ jarIndex: 1, name: 'Jar 1', color: '#111', balanceSummary: {}, utxos: [] },
],
walletBalanceSummary: { calculatedTotalBalanceInSats: 14000 },
}),
}))
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onSelect,
disabled,
isSelected,
}: {
name?: string
onSelect?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onSelect={onSelect} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),
}))
const getBaseWizard = (): Wizard =>
@ -73,8 +34,15 @@ const getBaseWizard = (): Wizard =>
step: 'select_date',
setSelectedLockdate: vi.fn(),
selectedLockdate: '2025-01',
selectedYear: '2025',
selectedMonth: '01',
minYear: 2024,
minMonth: 1,
yearOptions: [{ value: '2025', label: '2025' }],
monthOptions: [{ value: '01', label: 'Jan' }],
clampLockdate: vi.fn((x: unknown) => x),
hasDuplicateLockdate: false,
existingFbLockdates: [],
selectedDateLabel: 'Jan 2025',
selectedJarIndex: 0,
setSelectedJarIndex: vi.fn(),
jarsWithUtxos: [],
@ -83,6 +51,8 @@ const getBaseWizard = (): Wizard =>
utxoPage: 0,
setUtxoPage: vi.fn(),
toggleUtxoSelection: vi.fn(),
selectAllUtxos: vi.fn(),
deselectAllUtxos: vi.fn(),
totalAmount: 1000,
isUsingAllFunds: false,
utxosToFreeze: [],
@ -102,7 +72,7 @@ describe('CreateFidelityBondDialogSteps', () => {
expect(screen.getByText('earn.fidelity_bond.select_date.description')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.form_label_month')).toBeInTheDocument()
expect(screen.getByText('January 1, 2025')).toBeInTheDocument()
expect(screen.getByText('Jan 2025')).toBeInTheDocument()
})
it('renders select_jar step with jars', () => {
@ -120,9 +90,8 @@ describe('CreateFidelityBondDialogSteps', () => {
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.select_jar.title')).toBeInTheDocument()
// Jar 0 is eligible, Jar 1 has no eligible utxos and is disabled
expect(screen.getByText('Jar 0')).toBeEnabled()
expect(screen.getByText('Jar 1')).toBeDisabled()
expect(screen.getByText('Jar 0')).toBeInTheDocument()
expect(screen.getByText('5000 sats')).toBeInTheDocument()
})
it('renders select_utxos step', () => {
@ -158,7 +127,7 @@ describe('CreateFidelityBondDialogSteps', () => {
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
expect(screen.getByText('bc1...')).toBeInTheDocument()
expect(screen.queryByTestId('qr-code')).not.toBeInTheDocument()
expect(screen.getByTestId('qr-code')).toBeInTheDocument()
})
it('renders creating step', () => {
@ -172,18 +141,18 @@ describe('CreateFidelityBondDialogSteps', () => {
it('renders success step', () => {
const wizard = getBaseWizard()
wizard.step = 'success'
wizard.txResult = { txinfo: { txid: '1234abcd', hex: '' } }
wizard.txResult = { txinfo: { txid: '1234abcd' } }
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('earn.fidelity_bond.create_fidelity_bond.success_text')).toBeInTheDocument()
expect(screen.getByText('1234abcd')).toBeInTheDocument()
})
it('passes existing fidelity-bond lock dates to the date selector', () => {
it('warns about a duplicate lock date on the select_date step', () => {
const wizard = getBaseWizard()
wizard.existingFbLockdates = ['2025-01']
wizard.hasDuplicateLockdate = true
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('January 1, 2025')).toBeInTheDocument()
expect(screen.getByTestId('trans-earn.fidelity_bond.select_date.warning_fb_with_same_expiry')).toBeInTheDocument()
})
it('highlights the selected jar and shows the no-jars alert state', () => {
@ -208,7 +177,7 @@ describe('CreateFidelityBondDialogSteps', () => {
] as unknown as Wizard['jarsWithUtxos']
render(<CreateFidelityBondDialogSteps wizard={wizard} />)
expect(screen.getByText('Jar 0')).toBeInTheDocument()
expect(screen.getByText('Jar 1')).toHaveAttribute('data-selected', 'true')
expect(screen.getByText('Jar 1')).toBeInTheDocument()
})
it('renders select_utxos with a selected utxo, pagination, and the all-funds warning', () => {

View file

@ -1,7 +1,23 @@
import { AlertTriangleIcon, CheckIcon, CalendarIcon, WalletIcon, CoinsIcon, LockIcon } from 'lucide-react'
import {
AlertTriangleIcon,
CheckCircle2Icon,
CheckIcon,
CopyIcon,
Loader2Icon,
CalendarIcon,
WalletIcon,
CoinsIcon,
LockIcon,
} from 'lucide-react'
import { Trans } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { buttonVariants } from '@/components/ui/button-variants'
import { Card, CardContent } from '@/components/ui/card'
import { BitcoinAddressQrCode } from '@/components/ui/jam/BitcoinQrCode'
import { CopyButton } from '@/components/ui/jam/CopyButton'
import { Label } from '@/components/ui/label'
import {
Pagination,
PaginationContent,
@ -9,22 +25,11 @@ import {
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import * as fb from '@/lib/fidelityBondUtils'
import { clamp, cn } from '@/lib/utils'
import {
AddressPreview,
ConfirmationToggle,
CopyableField,
FidelityBondAmount,
InfoCard,
InlineLoading,
JarBadge,
PendingStep,
StepIntro,
SuccessHeading,
} from '../fidelity-bond/FidelityBondDialogParts'
import { FidelityBondJarSelector } from '../fidelity-bond/FidelityBondJarSelector'
import { LockdateSelect } from '../fidelity-bond/LockdateSelect'
import { clamp, cn, formatSats } from '@/lib/utils'
import type { useCreateFidelityBondWizard } from './useCreateFidelityBondWizard'
type Wizard = ReturnType<typeof useCreateFidelityBondWizard>
@ -38,7 +43,15 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
step,
setSelectedLockdate,
selectedLockdate,
existingFbLockdates,
selectedYear,
selectedMonth,
minYear,
minMonth,
yearOptions,
monthOptions,
clampLockdate,
hasDuplicateLockdate,
selectedDateLabel,
selectedJarIndex,
setSelectedJarIndex,
jarsWithUtxos,
@ -47,6 +60,8 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
utxoPage,
setUtxoPage,
toggleUtxoSelection,
selectAllUtxos,
deselectAllUtxos,
totalAmount,
isUsingAllFunds,
utxosToFreeze,
@ -59,42 +74,144 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
t,
} = wizard
const selectedJar = jarsWithUtxos.find((jar) => jar.jarIndex === selectedJarIndex)
const selectedDateLabel = selectedLockdate ? fb.lockdate.toDateLabel(selectedLockdate) : null
switch (step) {
case 'select_date':
return (
<div className="space-y-6">
<StepIntro
icon={CalendarIcon}
title={t('earn.fidelity_bond.select_date.description')}
subtitle={t('earn.fidelity_bond.select_date.subtitle')}
/>
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<CalendarIcon className="text-primary h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-medium">{t('earn.fidelity_bond.select_date.description')}</p>
<p className="text-muted-foreground mt-1 text-sm">{t('earn.fidelity_bond.select_date.subtitle')}</p>
</div>
</div>
<LockdateSelect
id="lockdate"
value={selectedLockdate}
onChange={setSelectedLockdate}
unavailableValues={existingFbLockdates}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lockdate-month" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_month')}
</Label>
<Select
value={selectedMonth}
onValueChange={(month) => {
const year = selectedYear || String(Number.parseInt(month, 10) >= minMonth ? minYear : minYear + 1)
const newLockdate = clampLockdate(`${year}-${month}`)
setSelectedLockdate(newLockdate)
}}
>
<SelectTrigger id="lockdate-month" className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
</SelectTrigger>
<SelectContent>
{monthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lockdate-year" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_year')}
</Label>
<Select
value={selectedYear}
onValueChange={(year) => {
const month = selectedMonth || String(year === String(minYear) ? minMonth : 1).padStart(2, '0')
const newLockdate = clampLockdate(`${year}-${month}`)
setSelectedLockdate(newLockdate)
}}
>
<SelectTrigger id="lockdate-year" className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
</SelectTrigger>
<SelectContent>
{yearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{selectedLockdate && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<p className="text-muted-foreground text-sm">
{t('earn.fidelity_bond.select_date.label_selected_lock_date')}
</p>
<p className="mt-1 text-lg font-semibold">{selectedDateLabel}</p>
</div>
)}
{hasDuplicateLockdate && (
<Alert variant="warning">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription>
<Trans i18nKey="earn.fidelity_bond.select_date.warning_fb_with_same_expiry">
<strong>Warning</strong>: A fidelity bond with the same expiry date already exists.
</Trans>
</AlertDescription>
</Alert>
)}
</div>
)
case 'select_jar':
return (
<div className="space-y-6">
<StepIntro
icon={WalletIcon}
title={t('earn.fidelity_bond.select_jar.title')}
subtitle={t('earn.fidelity_bond.select_jar.description')}
/>
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<WalletIcon className="text-primary h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-medium">{t('earn.fidelity_bond.select_jar.title')}</p>
<p className="text-muted-foreground mt-1 text-sm">{t('earn.fidelity_bond.select_jar.description')}</p>
</div>
</div>
<FidelityBondJarSelector
selectedJarIndex={selectedJarIndex}
onSelect={setSelectedJarIndex}
isJarDisabled={(jar) => !jarsWithUtxos.some((eligible) => eligible.jarIndex === jar.jarIndex)}
/>
<div className="grid gap-3">
{jarsWithUtxos.map((jar) => {
const isSelected = selectedJarIndex === jar.jarIndex
return (
<Card
key={jar.jarIndex}
className={cn(
'cursor-pointer transition-all duration-200 hover:shadow-md',
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'hover:bg-muted/30',
)}
onClick={() => setSelectedJarIndex(jar.jarIndex)}
>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div
className="h-10 w-10 rounded-full shadow-sm"
style={{ backgroundColor: jar.color, opacity: 0.8 }}
/>
<div>
<p className="font-semibold">{jar.name}</p>
<p className="text-muted-foreground text-sm">
{t('earn.fidelity_bond.select_jar.text_available_utxos', {
count: jar.utxos.filter((u) => !u.frozen && !fb.utxo.isFidelityBond(u)).length,
})}
</p>
</div>
</div>
<div className="text-right">
<p className="font-mono font-semibold">
{formatSats(jar.balanceSummary.calculatedAvailableBalanceInSats)}
</p>
{isSelected && <CheckCircle2Icon className="text-primary mt-1 ml-auto h-5 w-5" />}
</div>
</CardContent>
</Card>
)
})}
</div>
{jarsWithUtxos.length === 0 && (
<Alert variant="warning">
<AlertTriangleIcon className="h-4 w-4" />
@ -107,11 +224,27 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
case 'select_utxos':
return (
<div className="space-y-6">
<StepIntro
icon={CoinsIcon}
title={t('earn.fidelity_bond.select_utxos.title')}
subtitle={t('earn.fidelity_bond.select_utxos.description', { jar: selectedJar?.name ?? selectedJarIndex })}
/>
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<CoinsIcon className="text-primary h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">{t('earn.fidelity_bond.select_utxos.title')}</p>
<p className="text-muted-foreground mt-1 text-sm">
{t('earn.fidelity_bond.select_utxos.description', { jar: selectedJarIndex })}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={selectAllUtxos} className="shadow-sm">
<CheckIcon className="mr-1 h-3.5 w-3.5" />
{t('earn.fidelity_bond.select_utxos.button_select_all')}
</Button>
<Button variant="outline" size="sm" onClick={deselectAllUtxos} className="shadow-sm">
{t('earn.fidelity_bond.select_utxos.button_deselect_all')}
</Button>
</div>
<div className="space-y-2">
{(() => {
@ -136,7 +269,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all',
'flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition-all',
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/40',
@ -153,10 +286,9 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
</p>
</div>
<div className="shrink-0 text-right">
<FidelityBondAmount
value={utxo.value}
className="text-sm font-semibold whitespace-nowrap"
/>
<p className="font-mono text-sm font-semibold whitespace-nowrap">
{formatSats(utxo.value)}
</p>
</div>
</CardContent>
</Card>
@ -192,11 +324,9 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
{selectedUtxos.length > 0 && (
<div className="border-t pt-4">
<div className="bg-muted/50 flex flex-wrap items-center justify-between gap-2 rounded-lg p-3">
<span className="min-w-0 font-medium break-words">
{t('earn.fidelity_bond.select_utxos.label_total_selected')}
</span>
<FidelityBondAmount value={totalAmount} className="shrink-0 text-lg" />
<div className="bg-muted/50 flex items-center justify-between rounded-lg p-3">
<span className="font-medium">{t('earn.fidelity_bond.select_utxos.label_total_selected')}</span>
<span className="font-mono text-lg font-bold">{formatSats(totalAmount)}</span>
</div>
</div>
)}
@ -220,14 +350,18 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
case 'freeze_utxos':
return (
<div className="space-y-6">
<StepIntro
icon={LockIcon}
title={t('earn.fidelity_bond.freeze_utxos.title')}
subtitle={t('earn.fidelity_bond.freeze_utxos.subtitle')}
/>
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<LockIcon className="text-primary h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-medium">{t('earn.fidelity_bond.freeze_utxos.title')}</p>
<p className="text-muted-foreground mt-1 text-sm">{t('earn.fidelity_bond.freeze_utxos.subtitle')}</p>
</div>
</div>
<div className="space-y-4">
<div className="border-brand-success/20 bg-brand-success/10 rounded-lg border p-4">
<div className="rounded-lg border border-green-500/20 bg-green-500/10 p-4">
<p className="mb-3 text-sm font-medium">
{t('earn.fidelity_bond.freeze_utxos.label_selected_utxos', { count: selectedUtxos.length })}
</p>
@ -235,14 +369,14 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
{selectedUtxos.map((utxo) => (
<div key={utxo.utxo} className="flex items-center justify-between py-1 text-sm">
<span className="mr-2 flex-1 truncate font-mono text-xs">{utxo.utxo.slice(0, 24)}...</span>
<FidelityBondAmount value={utxo.value} className="text-sm font-semibold" />
<span className="font-mono font-semibold">{formatSats(utxo.value)}</span>
</div>
))}
</div>
</div>
{utxosToFreeze.length > 0 && (
<div className="border-brand-warning/20 bg-brand-warning/10 rounded-lg border p-4">
<div className="rounded-lg border border-orange-500/20 bg-orange-500/10 p-4">
<p className="mb-1 text-sm font-medium">
{t('earn.fidelity_bond.freeze_utxos.label_utxos_to_freeze', { count: utxosToFreeze.length })}
</p>
@ -255,7 +389,7 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
{utxosToFreeze.map((utxo) => (
<div key={utxo.utxo} className="flex items-center justify-between py-1 text-sm">
<span className="mr-2 flex-1 truncate font-mono text-xs">{utxo.utxo.slice(0, 24)}...</span>
<FidelityBondAmount value={utxo.value} className="text-sm font-semibold" />
<span className="font-mono font-semibold">{formatSats(utxo.value)}</span>
</div>
))}
</div>
@ -269,24 +403,57 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_lock_date')}>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
</p>
<p className="font-semibold">{selectedDateLabel}</p>
</InfoCard>
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
<JarBadge jarIndex={selectedJarIndex} name={selectedJar?.name} />
</InfoCard>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">{t('earn.fidelity_bond.review_inputs.label_jar')}</p>
<p className="font-semibold">
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: selectedJarIndex })}
</p>
</div>
</div>
<InfoCard highlight label={t('earn.fidelity_bond.review_inputs.label_amount')}>
<FidelityBondAmount value={totalAmount} />
</InfoCard>
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<p className="text-muted-foreground mb-1 text-xs">{t('earn.fidelity_bond.review_inputs.label_amount')}</p>
<p className="font-mono text-2xl font-bold">{formatSats(totalAmount)}</p>
</div>
</div>
{timelockAddressQuery.isLoading ? (
<InlineLoading text={t('earn.fidelity_bond.text_loading')} />
<div className="flex items-center justify-center py-8">
<div className="text-center">
<Loader2Icon className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-3">{t('earn.fidelity_bond.text_loading')}</p>
</div>
</div>
) : (
address && <AddressPreview label={t('earn.fidelity_bond.review_inputs.label_address')} address={address} />
address && (
<div className="space-y-4">
<div className="dark:bg-muted/30 flex justify-center rounded-lg bg-white p-4">
<BitcoinAddressQrCode address={address} amount={totalAmount} width={200} />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">{t('earn.fidelity_bond.review_inputs.label_address')}</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">{address}</code>
<CopyButton
key="copy-address-review"
value={address}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() => toast.success(t('receive.text_copy_address'))}
onError={() => toast.error(t('receive.error_copy_address_failed'))}
/>
</div>
</div>
</div>
)
)}
<Alert variant="warning">
@ -300,50 +467,105 @@ export function CreateFidelityBondDialogSteps({ wizard }: CreateFidelityBondDial
</AlertDescription>
</Alert>
<ConfirmationToggle
id="confirmation"
checked={confirmationChecked}
onCheckedChange={setConfirmationChecked}
/>
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
<Switch
id="confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) => setConfirmationChecked(checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label htmlFor="confirmation" className="cursor-pointer text-sm font-medium">
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
</Label>
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
</p>
</div>
</div>
</div>
)
case 'creating':
return (
<PendingStep
icon={LockIcon}
title={t('earn.fidelity_bond.text_creating')}
subtitle={t('earn.fidelity_bond.text_creating_subtitle')}
/>
<div className="flex flex-col items-center justify-center py-12">
<div className="relative">
<Spinner className="h-16 w-16" />
<div className="absolute inset-0 flex items-center justify-center">
<LockIcon className="text-primary h-6 w-6 animate-pulse" />
</div>
</div>
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.text_creating')}</p>
<p className="text-muted-foreground mt-2 text-sm">{t('earn.fidelity_bond.text_creating_subtitle')}</p>
</div>
)
case 'success':
return (
<div className="space-y-6">
<SuccessHeading
title={t('earn.fidelity_bond.create_fidelity_bond.success_text')}
subtitle={t('earn.fidelity_bond.create_fidelity_bond.text_success_subtitle')}
/>
<div className="flex flex-col items-center py-6">
<div className="rounded-full bg-green-500/10 p-4">
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
</div>
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.create_fidelity_bond.success_text')}</p>
<p className="text-muted-foreground mt-2 text-sm">
{t('earn.fidelity_bond.create_fidelity_bond.text_success_subtitle')}
</p>
</div>
<div className="space-y-3">
<InfoCard label={t('earn.fidelity_bond.create_fidelity_bond.label_lock_date')}>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.create_fidelity_bond.label_lock_date')}
</p>
<p className="font-semibold">{selectedDateLabel}</p>
</InfoCard>
</div>
{address && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
value={address}
copiedMessage={t('receive.text_copy_address')}
/>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_address')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">{address}</code>
<CopyButton
key="copy-address-success"
value={address}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() => toast.success(t('receive.text_copy_address'))}
onError={() => toast.error(t('receive.error_copy_address_failed'))}
/>
</div>
</div>
)}
{txResult?.txinfo?.txid && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
value={txResult.txinfo.txid}
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
/>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
{txResult.txinfo.txid}
</code>
<CopyButton
key="copy-txid"
value={txResult.txinfo.txid}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() =>
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
}
onError={(error) => {
const reason =
(error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown')
toast.error(t('global.errors.error_copy_to_clipboard_failed', { reason }))
}}
/>
</div>
</div>
)}
</div>

View file

@ -1,7 +1,6 @@
import type { TFunction } from 'i18next'
import * as yup from 'yup'
import * as fb from '@/lib/fidelityBondUtils'
import { sourceJarField } from '@/lib/formValidation'
import type { JarIndex } from '@/types/global'
export type SourceValue = {
@ -30,8 +29,10 @@ export const createFidelityBondFormSchema = (
t: TFunction<'translation', undefined>,
): yup.ObjectSchema<CreateFidelityBondFormValues> => {
const requiredMessage = t('global.errors.reason_unknown')
/* TODO: i18n: provide dedicated fidelity-bond source-jar validation copy */
const invalidSourceJarFeedbackMessage = t('receive.feedback_invalid_source_jar')
const invalidSourceJarFeedbackMessage = t('receive.feedback_invalid_source_jar', {
/* TODO: i18n: provide dedicated fidelity-bond source-jar validation copy */
defaultValue: 'Please select a source jar.',
})
const validLockdates = lockdateOptions.map((option) => option.value)
return yup
@ -39,7 +40,15 @@ export const createFidelityBondFormSchema = (
lockdate: yup.string<fb.Lockdate>().oneOf(validLockdates, requiredMessage).required(requiredMessage),
source: yup
.object({
fromJar: sourceJarField(invalidSourceJarFeedbackMessage, (jarIndex) => jarIndexes.includes(jarIndex)),
fromJar: yup
.number<JarIndex>()
.integer(invalidSourceJarFeedbackMessage)
.required(invalidSourceJarFeedbackMessage)
.test(
'valid-source-jar-index-test',
invalidSourceJarFeedbackMessage,
(value) => typeof value === 'number' && jarIndexes.includes(value),
),
})
.required(),
utxoIds: yup.array().of(yup.string().required(requiredMessage)).min(1, requiredMessage).required(requiredMessage),

View file

@ -1,29 +1,22 @@
import { render, screen } from '@testing-library/react'
import { render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { StepProgress } from './StepProgress'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))
describe('StepProgress', () => {
it('renders correct number of steps and the step label', () => {
it('renders correct number of steps', () => {
const { container } = render(<StepProgress currentStep={0} totalSteps={3} />)
const steps = container.querySelectorAll('.rounded-full')
const steps = container.firstChild?.childNodes
expect(steps).toHaveLength(3)
expect(screen.getByText('global.step_label {"current":1,"total":3}')).toBeInTheDocument()
})
it('applies correct classes based on currentStep', () => {
const { container } = render(<StepProgress currentStep={1} totalSteps={3} />)
const steps = container.querySelectorAll('.rounded-full')
const steps = container.firstChild?.childNodes as NodeListOf<HTMLElement>
// Step 0: completed (i < currentStep)
expect(steps[0].className).toContain('bg-primary w-8')

View file

@ -0,0 +1,22 @@
import { cn } from '@/lib/utils'
type StepProgressProps = {
currentStep: number
totalSteps: number
}
export function StepProgress({ currentStep, totalSteps }: StepProgressProps) {
return (
<div className="mb-6 flex items-center justify-center gap-2">
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={cn(
'h-1.5 rounded-full transition-all duration-300',
i < currentStep ? 'bg-primary w-8' : i === currentStep ? 'bg-primary w-12' : 'bg-muted w-8',
)}
/>
))}
</div>
)
}

View file

@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { generateLockdateOptions } from './types'
describe('generateLockdateOptions', () => {
it.each([false, true])('generates well-formed, ordered YYYY-MM values (developer mode: %s)', (isDeveloperMode) => {
const values = generateLockdateOptions(isDeveloperMode).map((option) => option.value)
for (const value of values) {
expect(value).toMatch(/^\d{4}-(0[1-9]|1[0-2])$/)
}
// zero-padded YYYY-MM sorts lexicographically === chronologically
expect(values.toSorted()).toEqual(values)
})
it('includes the past 12 months in developer mode', () => {
const values = generateLockdateOptions(true).map((option) => option.value)
const now = new Date()
const oneYearAgo = `${now.getUTCFullYear() - 1}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`
expect(values[0]).toBe(oneYearAgo)
expect(values).toHaveLength(133) // -12 .. +120 months
})
})

View file

@ -24,8 +24,7 @@ export function generateLockdateOptions(isDeveloperMode: boolean): { value: fb.L
for (let i = startOffset; i <= 120; i++) {
const monthOffset = startMonth + i
const year = startYear + Math.floor(monthOffset / 12)
// floored modulo — monthOffset is negative for past months in dev mode
const month = (((monthOffset % 12) + 12) % 12) + 1
const month = (monthOffset % 12) + 1
const lockdate = `${year}-${month.toString().padStart(2, '0')}` as fb.Lockdate
const date = new Date(Date.UTC(year, month - 1, 1))
const label = date.toLocaleDateString(undefined, { year: 'numeric', month: 'long' })

View file

@ -2,31 +2,22 @@ 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'
const mocks = vi.hoisted(() => {
const addressSummary: Record<string, { status: string }> = {
bcrt1qsource: { status: 'cj-out' },
}
return {
directSendMutateAsync: vi.fn(),
freezeMutateAsync: vi.fn(),
walletInfoRefetch: vi.fn(),
toastSuccess: vi.fn(),
queryAddress: undefined as string | undefined,
isDeveloperMode: false,
walletInfo: {
jars: [] as Jar[],
fidelityBondSummary: { fbOutputs: [] as Utxo[] },
addressSummary,
refetch: vi.fn(),
},
}
})
const mocks = vi.hoisted(() => ({
directSendMutateAsync: vi.fn(),
freezeMutateAsync: vi.fn(),
walletInfoRefetch: vi.fn(),
toastSuccess: vi.fn(),
queryAddress: undefined as string | undefined,
walletInfo: {
jars: [] as Jar[],
fidelityBondSummary: { fbOutputs: [] as Utxo[] },
refetch: vi.fn(),
},
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationKey: ['directsend'] })),
freezeMutation: vi.fn(() => ({ mutationKey: ['freeze'] })),
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['timelock-address'] })),
@ -67,7 +58,7 @@ vi.mock('@/hooks/useApiClient', () => ({
vi.mock('@/store/jamSettingsStore', async (importOriginal) => ({
...(await importOriginal<typeof import('@/store/jamSettingsStore')>()),
useDeveloperMode: () => ({ enabled: mocks.isDeveloperMode }),
useDeveloperMode: () => ({ enabled: false }),
}))
const utxo = (overrides: Partial<Utxo>): Utxo => ({
@ -91,12 +82,12 @@ const jar = (jarIndex: number, utxos: Utxo[]): Jar =>
jarIndex,
name: `Jar ${jarIndex}`,
color: '#808080',
balanceSummary: BALANCE_SUMMARY_EMPTY,
balanceSummary: {},
utxos,
}) as unknown as Jar
const minLockdateOption = (isDeveloperMode = false) => {
const lockdate = generateLockdateOptions(isDeveloperMode).at(0)?.value
const lockdateFromWizard = (wizard: ReturnType<typeof useCreateFidelityBondWizard>) => {
const lockdate = wizard.clampLockdate('1900-01')
if (!lockdate) throw new Error('Expected a generated lockdate option')
return lockdate
}
@ -109,11 +100,7 @@ describe('useCreateFidelityBondWizard', () => {
mocks.walletInfoRefetch.mockResolvedValue(undefined)
mocks.walletInfo.refetch = mocks.walletInfoRefetch
mocks.queryAddress = 'bcrt1qtimelockdestination'
mocks.isDeveloperMode = false
mocks.walletInfo.fidelityBondSummary = { fbOutputs: [] }
mocks.walletInfo.addressSummary = {
bcrt1qsource: { status: 'cj-out' },
}
mocks.walletInfo.jars = [
jar(0, [
utxo({ utxo: 'small:0', value: 10_000 }),
@ -130,26 +117,26 @@ describe('useCreateFidelityBondWizard', () => {
]
})
it('derives selectable utxos and keeps selection to one utxo', async () => {
it('derives selectable utxos and selection totals from wallet state', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
expect(result.current.availableUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0'])
expect(result.current.totalAmount).toBe(50_000)
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual(['small:0'])
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
expect(result.current.totalAmount).toBe(60_000)
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual([])
expect(result.current.isUsingAllFunds).toBe(false)
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[1]))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['small:0'])
expect(result.current.utxosToFreeze.map((entry) => entry.utxo)).toEqual(['large:0'])
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[1]))
act(() => result.current.deselectAllUtxos())
await waitFor(() => expect(result.current.selectedUtxos).toHaveLength(0))
})
@ -157,7 +144,7 @@ describe('useCreateFidelityBondWizard', () => {
it('skips jar selection when only one jar has eligible utxos', async () => {
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = minLockdateOption()
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
await act(async () => result.current.handleNext())
@ -165,7 +152,7 @@ describe('useCreateFidelityBondWizard', () => {
expect(result.current.step).toBe('select_utxos')
expect(result.current.selectedJarIndex).toBe(0)
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('review')
@ -189,7 +176,7 @@ describe('useCreateFidelityBondWizard', () => {
it('freezes unselected utxos before review and supports back navigation', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = minLockdateOption()
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
await act(async () => result.current.handleNext())
@ -224,11 +211,7 @@ describe('useCreateFidelityBondWizard', () => {
act(() => {
result.current.setStep('success')
result.current.setSelectedJarIndex(0)
})
act(() => {
result.current.toggleUtxoSelection(result.current.availableUtxos[0])
})
act(() => {
result.current.selectAllUtxos()
result.current.handleOpenChange(false)
})
@ -252,8 +235,40 @@ describe('useCreateFidelityBondWizard', () => {
expect(result.current.step).toBe('review')
})
it('exposes lockdate boundaries and clamps out-of-range values', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const { minYear, minMonth, yearOptions, monthOptions } = result.current
expect(minYear).toBeGreaterThan(0)
expect(minMonth).toBeGreaterThanOrEqual(1)
expect(yearOptions.length).toBeGreaterThan(0)
expect(monthOptions.length).toBe(12)
const min = result.current.clampLockdate('1900-01')
const max = result.current.clampLockdate('9999-12')
expect(min).not.toBe('')
expect(max).not.toBe('')
expect(min < max).toBe(true)
// empty input clamps to min
expect(result.current.clampLockdate('')).toBe(min)
// a value already inside the range is returned untouched
expect(result.current.clampLockdate(min)).toBe(min)
// derived year/month default to empty before a selection
expect(result.current.selectedYear).toBe('')
expect(result.current.selectedMonth).toBe('')
act(() => result.current.setSelectedLockdate(min))
expect(result.current.selectedYear).toBe(min.slice(0, 4))
expect(result.current.selectedMonth).toBe(min.slice(5, 7))
expect(result.current.selectedDateLabel).not.toBeNull()
})
it('flags duplicate lockdates against existing fidelity bonds', () => {
const lockdate = minLockdateOption()
const { result: probe } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = lockdateFromWizard(probe.current)
const timestampSeconds = Math.floor(new Date(`${lockdate}-01T00:00:00Z`).getTime() / 1000)
mocks.walletInfo.fidelityBondSummary = {
@ -272,31 +287,6 @@ describe('useCreateFidelityBondWizard', () => {
expect(result.current.hasDuplicateLockdate).toBeFalsy()
act(() => result.current.setSelectedLockdate(lockdate))
expect(result.current.hasDuplicateLockdate).toBe(true)
expect(result.current.canProceed()).toBe(false)
})
it('blocks a lock date already used by an expired fidelity bond', () => {
mocks.isDeveloperMode = true
const expiredLockdate = minLockdateOption(true)
const timestampSeconds = Math.floor(new Date(`${expiredLockdate}-01T00:00:00Z`).getTime() / 1000)
mocks.walletInfo.fidelityBondSummary = {
fbOutputs: [
utxo({
utxo: 'expired-bond:0',
value: 100_000,
locktime: `${expiredLockdate}-01 00:00:00`,
path: `m/84'/1'/0'/0/0:${timestampSeconds}`,
}),
],
}
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
expect(result.current.existingFbLockdates).toContain(expiredLockdate)
act(() => result.current.setSelectedLockdate(expiredLockdate))
expect(result.current.hasDuplicateLockdate).toBe(true)
expect(result.current.canProceed()).toBe(false)
})
it('ignores existing fidelity bonds whose locktime cannot be derived', () => {
@ -313,7 +303,7 @@ describe('useCreateFidelityBondWizard', () => {
}
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = minLockdateOption()
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
expect(result.current.hasDuplicateLockdate).toBeFalsy()
@ -323,8 +313,8 @@ describe('useCreateFidelityBondWizard', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0'])
act(() => result.current.selectAllUtxos())
expect(result.current.selectedUtxos.map((entry) => entry.utxo)).toEqual(['large:0', 'small:0'])
// switch to a jar that does not contain the previously selected ids
act(() => result.current.setSelectedJarIndex(1))
@ -332,26 +322,9 @@ describe('useCreateFidelityBondWizard', () => {
await waitFor(() => expect(result.current.selectedUtxos).toEqual([]))
})
it('only exposes unfrozen cj-out utxos for fidelity-bond creation', () => {
mocks.walletInfo.addressSummary = {
bcrt1qcj: { status: 'cj-out' },
bcrt1qdeposit: { status: 'deposit' },
}
mocks.walletInfo.jars = [
jar(0, [utxo({ utxo: 'cj:0', address: 'bcrt1qcj' }), utxo({ utxo: 'deposit:0', address: 'bcrt1qdeposit' })]),
jar(1, [utxo({ utxo: 'deposit-only:0', address: 'bcrt1qdeposit', mixdepth: 1 })]),
]
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
expect(result.current.jarsWithUtxos.map((entry) => entry.jarIndex)).toEqual([0])
act(() => result.current.setSelectedJarIndex(0))
expect(result.current.availableUtxos.map((entry) => entry.utxo)).toEqual(['cj:0'])
})
it('reflects canProceed gating for each step', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = minLockdateOption()
const lockdate = lockdateFromWizard(result.current)
// select_date
expect(result.current.canProceed()).toBe(false)
@ -367,7 +340,7 @@ describe('useCreateFidelityBondWizard', () => {
// select_utxos
act(() => result.current.setStep('select_utxos'))
expect(result.current.canProceed()).toBe(false)
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
expect(result.current.canProceed()).toBe(true)
// freeze_utxos always proceeds
@ -446,16 +419,19 @@ describe('useCreateFidelityBondWizard', () => {
expect(result.current.step).toBe('select_utxos')
})
it('skips freezing and moves straight to review when the selected utxo is the only one in the jar', async () => {
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
it('skips freezing and moves straight to review when all utxos are selected', async () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
const lockdate = minLockdateOption()
const lockdate = lockdateFromWizard(result.current)
act(() => result.current.setSelectedLockdate(lockdate))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_jar')
act(() => result.current.setSelectedJarIndex(0))
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('select_utxos')
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
await act(async () => result.current.handleNext())
expect(result.current.step).toBe('review')
@ -467,7 +443,7 @@ describe('useCreateFidelityBondWizard', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('review'))
// confirmation not accepted -> trigger() fails the whole form
await act(async () => result.current.handleNext())
@ -491,7 +467,7 @@ describe('useCreateFidelityBondWizard', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('select_utxos'))
act(() => result.current.handleBack())
@ -505,7 +481,7 @@ describe('useCreateFidelityBondWizard', () => {
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('select_utxos'))
act(() => result.current.handleBack())
@ -524,11 +500,10 @@ describe('useCreateFidelityBondWizard', () => {
})
it('returns from review to select_utxos when nothing was frozen', () => {
mocks.walletInfo.jars = [jar(0, [utxo({ utxo: 'only:0', value: 25_000 })])]
const { result } = renderHook(() => useCreateFidelityBondWizard(true, vi.fn(), 'wallet.jmdat'))
act(() => result.current.setSelectedJarIndex(0))
act(() => result.current.toggleUtxoSelection(result.current.availableUtxos[0]))
act(() => result.current.selectAllUtxos())
act(() => result.current.setStep('review'))
expect(result.current.utxosToFreeze).toEqual([])
act(() => result.current.handleBack())

View file

@ -1,30 +1,30 @@
import { useState, useMemo } 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 {
directsendMutation,
freezeMutation,
gettimelockaddressOptions,
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, 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 { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { delayedPromise, type WalletFileName } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import type { JarIndex } from '@/types/global'
import { useFidelityBondMutations } from '../fidelity-bond/useFidelityBondMutations'
import {
CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES,
createFidelityBondFormSchema,
type CreateFidelityBondFormValues,
} from './CreateFidelityBondFormSchema'
import type { Step } from './types'
import { generateLockdateOptions } from './types'
import { generateLockdateOptions, getYearOptions, getMonthOptions } from './types'
export function useCreateFidelityBondWizard(
open: boolean,
@ -40,29 +40,13 @@ export function useCreateFidelityBondWizard(
const [frozenUtxos, setFrozenUtxos] = useState<Utxo[]>([])
const [utxoPage, setUtxoPage] = useState(0)
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.error_creating_fidelity_bond',
})
const [error, setError] = useState<string | undefined>()
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
const existingFbLockdates = useMemo(() => {
return walletInfo.fidelityBondSummary.fbOutputs
.map((fbUtxo) => {
const locktime = fb.utxo.getLocktime(fbUtxo)
return locktime ? fb.lockdate.fromTimestamp(locktime) : null
})
.filter(Boolean) as fb.Lockdate[]
}, [walletInfo.fidelityBondSummary.fbOutputs])
const availableLockdateOptions = useMemo(
() => lockdateOptions.filter((option) => !existingFbLockdates.includes(option.value)),
[existingFbLockdates, lockdateOptions],
)
const jarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars])
const formSchema = useMemo(
() => createFidelityBondFormSchema(availableLockdateOptions, jarIndexes, t),
[availableLockdateOptions, jarIndexes, t],
() => createFidelityBondFormSchema(lockdateOptions, jarIndexes, t),
[lockdateOptions, jarIndexes, t],
)
const form = useForm<CreateFidelityBondFormValues, unknown, CreateFidelityBondFormValues>({
mode: 'onChange',
@ -71,7 +55,7 @@ export function useCreateFidelityBondWizard(
})
const watchedLockdate = useWatch({ control: form.control, name: 'lockdate' })
const selectedLockdate: fb.Lockdate | '' = watchedLockdate ?? ''
const selectedLockdate = watchedLockdate ?? ''
const selectedJarIndex = useWatch({ control: form.control, name: 'source.fromJar' })
const selectedUtxoIds = useWatch({
control: form.control,
@ -84,6 +68,20 @@ export function useCreateFidelityBondWizard(
defaultValue: CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES.confirmationAccepted,
})
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
const monthOptions = useMemo(() => getMonthOptions(), [])
const minLockdate = lockdateOptions.at(0)?.value ?? ''
const maxLockdate = lockdateOptions.at(-1)?.value ?? ''
const clampLockdate = (lockdate: string): fb.Lockdate | '' => {
if (!lockdate || lockdate < minLockdate) return minLockdate || ''
if (lockdate > maxLockdate) return maxLockdate || ''
return lockdate as fb.Lockdate
}
const selectedYear = selectedLockdate ? selectedLockdate.slice(0, 4) : ''
const selectedMonth = selectedLockdate ? selectedLockdate.slice(5, 7) : ''
const minYear = minLockdate ? Number.parseInt(minLockdate.slice(0, 4), 10) : 0
const minMonth = minLockdate ? Number.parseInt(minLockdate.slice(5, 7), 10) : 1
const setSelectedLockdate = (lockdate: fb.Lockdate | '') => {
form.setValue('lockdate', lockdate || undefined, { shouldDirty: true, shouldTouch: true, shouldValidate: true })
}
@ -101,6 +99,16 @@ export function useCreateFidelityBondWizard(
})
}
const existingFbLockdates = useMemo(() => {
return walletInfo.fidelityBondSummary.fbOutputs
.filter((fbUtxo) => fb.utxo.isLocked(fbUtxo))
.map((fbUtxo) => {
const locktime = fb.utxo.getLocktime(fbUtxo)
return locktime ? fb.lockdate.fromTimestamp(locktime) : null
})
.filter(Boolean) as fb.Lockdate[]
}, [walletInfo.fidelityBondSummary.fbOutputs])
const hasDuplicateLockdate = selectedLockdate && existingFbLockdates.includes(selectedLockdate)
const selectedJar = useMemo(() => {
@ -110,19 +118,21 @@ export function useCreateFidelityBondWizard(
const jarsWithUtxos = useMemo(() => {
return walletInfo.jars.filter((jar) => {
const availableUtxos = jar.utxos.filter((utxo) =>
fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status),
)
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
const availableUtxos = jar.utxos.filter((utxo) => !utxo.frozen && !fb.utxo.isFidelityBond(utxo))
return availableUtxos.length > 0
})
}, [walletInfo.addressSummary, walletInfo.jars])
}, [walletInfo.jars])
const availableUtxos = useMemo(() => {
if (selectedJar === undefined) return []
return selectedJar.utxos
.filter((utxo) => fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status))
.toSorted((a, b) => b.value - a.value)
}, [selectedJar, walletInfo.addressSummary])
return (
selectedJar.utxos
// TODO: let's allow selecting frozen utxos - unfreeze them before sending the transaction
.filter((utxo) => !utxo.frozen && !fb.utxo.isFidelityBond(utxo))
.toSorted((a, b) => b.value - a.value)
)
}, [selectedJar])
const selectedUtxos = useMemo(() => {
const availableUtxosById = new Map<string, Utxo>(availableUtxos.map((utxo) => [utxo.utxo, utxo]))
@ -161,6 +171,30 @@ export function useCreateFidelityBondWizard(
const address = timelockAddressQuery.data?.address
const freezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_freezing_utxos')} ${reason}`)
},
})
const unfreezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_unfreezing_utxos')} ${reason}`)
},
})
const directSend = useMutation({
...directsendMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_creating_fidelity_bond')} ${reason}`)
},
})
const handleReset = () => {
setStep('select_date')
form.reset(CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES)
@ -277,15 +311,17 @@ export function useCreateFidelityBondWizard(
setStep('creating')
try {
// TODO: refactor: use the sweep functionality from `useFidelityBondSweep` here
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: selectedJar.jarIndex,
amount_sats: 0, // 0 := sweep!
amount_sats: 0,
destination: address,
},
})
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const utxo of frozenUtxos) {
@ -293,33 +329,12 @@ export function useCreateFidelityBondWizard(
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO.')
} catch {
// logged via onError
}
}
// freeze resulting fidelity bond output
if (JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS) {
try {
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
}
}
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.create_fidelity_bond.success_text'))
await walletInfo.refetch()
} catch {
// Best-effort rollback — unfreeze UTXOs that were frozen for this operation
@ -329,9 +344,8 @@ export function useCreateFidelityBondWizard(
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
} catch {
// logged via onError
}
}
setFrozenUtxos([])
@ -356,14 +370,28 @@ export function useCreateFidelityBondWizard(
}
const toggleUtxoSelection = (utxo: Utxo) => {
// TODO: select/deselect all utxos to the same address
const isSelected = selectedUtxoIds.includes(utxo.utxo)
setSelectedUtxoIds(isSelected ? [] : [utxo.utxo])
setSelectedUtxoIds(
isSelected ? selectedUtxoIds.filter((utxoId) => utxoId !== utxo.utxo) : [...selectedUtxoIds, utxo.utxo],
)
}
const selectAllUtxos = () => setSelectedUtxoIds(availableUtxos.map((utxo) => utxo.utxo))
const deselectAllUtxos = () => setSelectedUtxoIds([])
const selectedDateLabel = selectedLockdate
? new Date(fb.lockdate.toTimestamp(selectedLockdate)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: null
const canProceed = () => {
switch (step) {
case 'select_date':
return !!selectedLockdate && !hasDuplicateLockdate
return !!selectedLockdate
case 'select_jar':
return selectedJar !== undefined
case 'select_utxos':
@ -387,8 +415,15 @@ export function useCreateFidelityBondWizard(
setStep,
setSelectedLockdate,
selectedLockdate,
selectedYear,
selectedMonth,
minYear,
minMonth,
yearOptions,
monthOptions,
clampLockdate,
hasDuplicateLockdate,
existingFbLockdates,
selectedDateLabel,
selectedJarIndex,
setSelectedJarIndex,
jarsWithUtxos,
@ -397,6 +432,8 @@ export function useCreateFidelityBondWizard(
utxoPage,
setUtxoPage,
toggleUtxoSelection,
selectAllUtxos,
deselectAllUtxos,
totalAmount,
isUsingAllFunds,
utxosToFreeze,

View file

@ -9,6 +9,10 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('@/components/ui/jam/CurrencySymbol', () => ({
SatSymbol: (props: Record<string, unknown>) => <span {...props}>sat-symbol</span>,
}))
vi.mock('../dev/DevBadge', () => ({
DevBadge: ({ className }: { className?: string }) => <span className={className}>dev-badge</span>,
}))

View file

@ -3,7 +3,7 @@ import { yupResolver } from '@hookform/resolvers/yup'
import type { TFunction } from 'i18next'
import { HandshakeIcon, PercentIcon } from 'lucide-react'
import { useForm, useWatch } from 'react-hook-form'
import type { SubmitHandler } from 'react-hook-form'
import type { Resolver, SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
@ -17,7 +17,7 @@ import { cn, factorToPercentage, isValidInteger, isValidNumber } from '@/lib/uti
import type { AmountSats } from '@/types/global'
import { DevBadge } from '../dev/DevBadge'
import { Card, CardContent, CardHeader } from '../ui/card'
import { Field, FieldDescription, FieldError, FieldLabel } from '../ui/field'
import { Field, FieldDescription, FieldLabel } from '../ui/field'
import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'
import { Spinner } from '../ui/spinner'
@ -90,8 +90,8 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
const id = useId()
return (
<RadioGroup className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
<RadioGroup className="flex items-center justify-center" {...props}>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 cursor-pointer flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
<RadioGroupItem
value={OFFERTYPE_ABS}
id={`${id}-sw0absoffer`}
@ -104,7 +104,7 @@ const OfferTypeInput = (props: React.ComponentProps<typeof RadioGroup>) => {
</Label>
</div>
</div>
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px] sm:max-w-50">
<div className="border-input has-data-[state=checked]:border-primary/50 has-data-[state=checked]:ring-primary/20 relative flex w-full max-w-50 flex-col items-center gap-3 rounded-md border p-4 shadow-xs outline-none has-data-[state=checked]:ring-[2px]">
<RadioGroupItem
value={OFFERTYPE_REL}
id={`${id}-sw0reloffer`}
@ -179,7 +179,8 @@ export function EarnForm({
} = useForm<EarnFormValues, unknown, EarnFormValues>({
mode: 'onSubmit',
defaultValues: FORM_INPUT_DEFAULT_VALUES,
resolver: yupResolver(schema),
// force type (see https://github.com/react-hook-form/resolvers/issues/807)
resolver: yupResolver(schema) as Resolver<EarnFormValues, unknown, EarnFormValues>,
})
const values = useWatch({ control })
@ -202,81 +203,89 @@ export function EarnForm({
/>
<Tabs value={watchOfferType}>
<TabsContent value={OFFERTYPE_ABS}>
<Field data-invalid={errors.offerAbsoluteFee !== undefined}>
<FieldLabel htmlFor="offerAbsoluteFee">
{t('earn.label_abs_fee', {
fee: '', // empty on purpose
})}
</FieldLabel>
<FieldDescription className="text-xs">{t('earn.description_abs_fee')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="offerAbsoluteFee"
{...register('offerAbsoluteFee', {
disabled,
<div className="space-y-2">
<Field data-invalid={errors.offerAbsoluteFee !== undefined}>
<FieldLabel htmlFor="offerAbsoluteFee">
{t('earn.label_abs_fee', {
fee: '', // empty on purpose
})}
type="number"
step={1}
/>
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
</InputGroup>
{errors.offerAbsoluteFee?.message && <FieldError>{errors.offerAbsoluteFee.message}</FieldError>}
</Field>
</FieldLabel>
<FieldDescription className="text-xs">{t('earn.description_abs_fee')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="offerAbsoluteFee"
{...register('offerAbsoluteFee', {
disabled,
})}
type="number"
step={1}
/>
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
</InputGroup>
</Field>
{errors.offerAbsoluteFee?.message && (
<div className="text-destructive text-xs">{errors.offerAbsoluteFee.message}</div>
)}
</div>
</TabsContent>
<TabsContent value={OFFERTYPE_REL}>
<Field data-invalid={errors.offerRelativeFeeInPercent !== undefined}>
<FieldLabel htmlFor="offerRelativeFeeInPercent">
{t('earn.label_rel_fee', {
fee: getValues('offerRelativeFeeInPercent') ? `(${getValues('offerRelativeFeeInPercent')!}%)` : '',
})}
</FieldLabel>
<FieldDescription className="text-xs">{t('earn.description_rel_fee')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="offerRelativeFeeInPercent"
{...register('offerRelativeFeeInPercent', {
disabled,
<div className="space-y-2">
<Field data-invalid={errors.offerRelativeFeeInPercent !== undefined}>
<FieldLabel htmlFor="offerRelativeFeeInPercent">
{t('earn.label_rel_fee', {
fee: getValues('offerRelativeFeeInPercent') ? `(${getValues('offerRelativeFeeInPercent')!}%)` : '',
})}
type="number"
step={factorToPercentage(JAM.OFFER_FEE_REL_STEP)}
/>
<InputGroupAddon align="inline-start">%</InputGroupAddon>
</InputGroup>
{errors.offerRelativeFeeInPercent && <FieldError>{errors.offerRelativeFeeInPercent.message}</FieldError>}
</Field>
</FieldLabel>
<FieldDescription className="text-xs">{t('earn.description_rel_fee')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="offerRelativeFeeInPercent"
{...register('offerRelativeFeeInPercent', {
disabled,
})}
type="number"
step={factorToPercentage(JAM.OFFER_FEE_REL_STEP)}
/>
<InputGroupAddon align="inline-start">%</InputGroupAddon>
</InputGroup>
</Field>
{errors.offerRelativeFeeInPercent && (
<div className="text-destructive text-xs">{errors.offerRelativeFeeInPercent.message}</div>
)}
</div>
</TabsContent>
</Tabs>
<Field data-invalid={errors.offerMinAmount !== undefined}>
<FieldLabel htmlFor="offerMinAmount">{t('earn.label_min_amount_input')}</FieldLabel>
<FieldDescription className="text-xs">{t('earn.description_min_amount_input')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="offerMinAmount"
{...register('offerMinAmount', {
required: true,
disabled,
})}
type="number"
max={offerMinsizeMax}
step={1}
placeholder={t('earn.placeholder_min_amount_input')}
/>
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
</InputGroup>
<div className="space-y-2">
<Field data-invalid={errors.offerMinAmount !== undefined}>
<FieldLabel htmlFor="offerMinAmount">{t('earn.label_min_amount_input')}</FieldLabel>
<FieldDescription className="text-xs">{t('earn.description_min_amount_input')}</FieldDescription>
<InputGroup>
<InputGroupInput
id="offerMinAmount"
{...register('offerMinAmount', {
required: true,
disabled,
})}
type="number"
max={offerMinsizeMax}
step={1}
placeholder={t('earn.placeholder_min_amount_input')}
/>
<InputGroupAddon align="inline-start">{FieldPrefixSatSymbol}</InputGroupAddon>
</InputGroup>
</Field>
{errors.offerMinAmount && (
<FieldError>
<div className="text-destructive text-xs">
{offerMinsizeMax < JAM.OFFER_MINSIZE_MIN ? (
<>{t('earn.feedback_invalid_min_amount_insufficient_funds')}</>
) : (
<>{errors.offerMinAmount?.message || t('earn.feedback_invalid_min_amount')}</>
)}
</FieldError>
</div>
)}
</Field>
</div>
<Button
type="submit"
variant={disabled && !isWaitingMakerStart ? 'outline' : undefined}
@ -301,11 +310,11 @@ export function EarnForm({
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="overflow-scroll">
<code className="text-destructive">isValid:</code>
<code className="light:text-red-700 text-red-800">isValid:</code>
<pre className="text-xs">{JSON.stringify(isValid, null, 2)}</pre>
</div>
<div className="overflow-scroll">
<code className="text-destructive">values:</code>
<code className="light:text-red-700 text-red-800">values:</code>
<pre className="text-xs">{JSON.stringify(values, null, 2)}</pre>
</div>
</CardContent>

View file

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

View file

@ -1,8 +1,8 @@
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 { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { ErrorMessage, StartMakerRequest } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { FileTextIcon, HourglassIcon, InfoIcon, PlusIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import { AlertTriangleIcon, FileTextIcon, HourglassIcon, RefreshCwIcon, ShuffleIcon, UnlockIcon } from 'lucide-react'
import type { SubmitHandler } from 'react-hook-form'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
@ -16,16 +16,13 @@ import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader,
import { FeeConfigErrorAlert } from '@/components/ui/jam/FeeConfigErrorAlert'
import { PageLoading } from '@/components/ui/jam/PageLoading'
import PageTitle from '@/components/ui/jam/PageTitle'
import { isDevMode } from '@/constants/debugFeatures'
import * as JAM from '@/constants/jam'
import { routes } from '@/constants/routes'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation'
import { useQueryOrderbook } from '@/hooks/useQueryOrderbook'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { useRefreshSession } from '@/hooks/useRefreshSession'
import * as OrderbookApi from '@/lib/api/orderbook'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { withQueryDelay } from '@/lib/queryClient'
@ -33,16 +30,25 @@ import { cn, isAbsoluteOffer, isRelativeOffer, percentageToFactor, scrollToTop }
import type { WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { jmSessionStore } from '@/store/jmSessionStore'
import type { Milliseconds } from '@/types/global'
import { Spinner } from '../ui/spinner'
import { CreateFidelityBondDialog } from './CreateFidelityBondDialog'
import { EarnForm, type EarnFormValues } from './EarnForm'
import { NonFrozenFidelityBondsAlert, NoSpendableBalanceAlert, UnconfirmedBalanceAlert } from './EarnPreconditionAlerts'
import { FidelityBondCard } from './FidelityBondCard'
import { MoveToJarDialog } from './MoveToJarDialog'
import { OfferCard } from './OfferCard'
import { RenewBondDialog } from './RenewBondDialog'
import { EarnReportOverlay } from './report/EarnReportOverlay'
// In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
// Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
// There is currently no way to know for sure - adding a delay at least mitigates the problem.
// 2022-04-26: With value of 2_000ms, no state corruption could be provoked in a local dev setup.
const MAKER_STOP_RESPONSE_DELAY: Milliseconds = 2_000
const WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL: Milliseconds = 3_000
const WAIT_FOR_UPDATE_SESSION_POLLING_DELAY: Milliseconds = 1_000
const toStartMakerRequest = (values: EarnFormValues): StartMakerRequest => {
// both fee properties need to be provided.
// prevent providing an invalid value by setting the ignored prop to zero
@ -61,7 +67,37 @@ interface EarnPageProps {
walletFileName: WalletFileName
}
const JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED = isDevMode()
const UnconfirmedBalanceAlert = () => {
const { t } = useTranslation()
return (
<Alert variant="warning" className="mb-4">
<AlertTriangleIcon />
<AlertTitle>{t('earn.alert_unconfirmed_balance_title')}</AlertTitle>
<AlertDescription>{t('earn.alert_unconfirmed_balance_description')}</AlertDescription>
</Alert>
)
}
const NoSpendableBalanceAlert = () => {
const { t } = useTranslation()
return (
<Alert variant="warning" className="mb-4">
<AlertTriangleIcon />
<AlertTitle>{t('earn.alert_no_spendable_balance_title')}</AlertTitle>
<AlertDescription>
<Trans i18nKey="earn.alert_no_spendable_balance_description">
Fund your wallet on the
<Link to={routes.receive} className="font-semibold">
Receive page
</Link>
to start earning.
</Trans>
</AlertDescription>
</Alert>
)
}
export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const { t } = useTranslation()
@ -75,6 +111,12 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const maxConfirmedJarAvailableBalance = useMemo(() => {
return Math.max(0, ...walletInfo.jars.map((jar) => jar.balanceSummary.calculatedConfirmedAvailableBalanceInSats))
}, [walletInfo.jars])
const earnBalanceWarning =
!walletInfo.isFetching && maxConfirmedJarAvailableBalance < JAM.OFFER_MINSIZE_MIN
? walletInfo.maxJarAvailableBalance >= JAM.OFFER_MINSIZE_MIN
? 'unconfirmed'
: 'unavailable'
: null
const [moveToJarUtxo, setMoveToJarUtxo] = useState<FidelityBondUtxo | undefined>()
const [renewBondUtxo, setRenewBondUtxo] = useState<FidelityBondUtxo | undefined>()
@ -84,34 +126,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const [showFeeConfigDialog, setShowFeeConfigDialog] = useState(false)
const isCurrentOfferAvailable = jmSession?.offer_list && jmSession.offer_list.length > 0
const currentOffer = jmSession?.offer_list?.[0]
const isCurrentOrderbookOffer = (offer: OrderbookApi.OrderbookOffer) =>
offer.counterparty === jmSession?.nickname && String(offer.oid) === String(currentOffer?.oid)
const {
queryResult: { data: orderbookData, isFetching: orderbookIsFetching, isError: orderbookIsError },
} = useQueryOrderbook({
enabled: makerRunning && !!currentOffer && !!jmSession?.nickname,
staleTime: Number.POSITIVE_INFINITY,
refetchInterval: (query) =>
query.state.error || !query.state.data?.offers.some((offer) => isCurrentOrderbookOffer(offer))
? JAM.WAIT_FOR_UPDATE_ORDERBOOK_POLLING_INTERVAL
: JAM.VISIBLE_ORDERBOOK_POLLING_INTERVAL,
})
const currentOrderbookOffer = orderbookData?.offers.find((offer) => isCurrentOrderbookOffer(offer))
const currentOrderbookFidelityBond =
Number(currentOrderbookOffer?.fidelity_bond_value) > 0
? orderbookData?.fidelitybonds?.findLast((bond) => bond.counterparty === jmSession?.nickname)
: undefined
const orderbookStatus =
!jmSession?.nickname || orderbookIsFetching
? 'checking'
: orderbookIsError
? 'error'
: currentOrderbookOffer
? 'visible'
: 'missing'
const stopMakerQueryOptions = stopmakerOptions({
client,
@ -121,7 +135,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const stopMakerQuery = useQuery({
...stopMakerQueryOptions,
queryFn: withQueryDelay(stopMakerQueryOptions.queryFn, {
delayAfter: JAM.MAKER_STOP_RESPONSE_DELAY,
delayAfter: MAKER_STOP_RESPONSE_DELAY,
}),
enabled: false,
retry: false,
@ -136,9 +150,10 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
onMutate: () => {
toast.info(t('earn.alert_stopping'), { id: 'earn.alert_stopping' })
},
onError: (error: unknown) => {
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
toast.error(t('earn.error_stopping_earn', { reason }))
// TODO: i18n
toast.error(`Error while stopping the earn process. Reason: ${reason}`)
},
})
@ -148,7 +163,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
onMutate: () => {
toast.info(t('earn.alert_starting'), { id: 'earn.alert_starting' })
},
onError: (error) => {
onError: (error: ErrorMessage) => {
console.error('StartMaker error:', error)
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
toast.error(t('earn.alert_start_failed', { reason }))
@ -161,38 +176,10 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const waitingForMakerUpdate = isWaitingMakerStart || isWaitingMakerStop
const waitingForOfferUpdate = makerRunning && !isCurrentOfferAvailable
const hasFidelityBond = walletInfo.fidelityBondSummary.fbOutputs.length > 0
const isFidelityBondActionsEnabled =
jmSession?.rescanning === false &&
jmSession.maker_running === false &&
jmSession.coinjoin_in_process === false &&
!waitingForMakerUpdate &&
!waitingForOfferUpdate &&
!walletInfo.isFetching
const isCreateFidelityBondEnabled =
isFidelityBondActionsEnabled &&
walletInfo.hasEligibleFidelityBondUtxo &&
(!hasFidelityBond || JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED)
const showCreateAdditionalFidelityBond = JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED
const numberOfNonFrozenFidelityBondOutputs = !hasFidelityBond
? 0
: walletInfo.fidelityBondSummary.fbOutputs.filter((it) => it.frozen !== true).length
const earnPreconditionWarning =
numberOfNonFrozenFidelityBondOutputs > 0
? 'non-frozen-fb-present'
: !walletInfo.isFetching && maxConfirmedJarAvailableBalance < JAM.OFFER_MINSIZE_MIN
? walletInfo.maxJarAvailableBalance >= JAM.OFFER_MINSIZE_MIN
? 'missing-utxos'
: 'missing-confirmations'
: undefined
useRefreshSession({
enabled: waitingForMakerUpdate || waitingForOfferUpdate,
refetchInterval: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: JAM.WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
refetchInterval: WAIT_FOR_UPDATE_SESSION_POLLING_INTERVAL,
refetchDelay: WAIT_FOR_UPDATE_SESSION_POLLING_DELAY,
})
const onStop = async () => {
@ -235,6 +222,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<div className="mx-auto max-w-4xl space-y-3 p-4">
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setShowEarnReport(true)}>
<FileTextIcon />
{t('earn.button_show_report')}
</Button>
</div>
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
@ -242,7 +236,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{jmSession.coinjoin_in_process === true && (
<Alert variant="warning">
<HourglassIcon className="motion-safe:animate-pulse" />
<AlertTitle>{t('send.text_coinjoin_already_running')}</AlertTitle>
<AlertDescription>{t('send.text_coinjoin_already_running')}</AlertDescription>
</Alert>
)}
@ -255,13 +249,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{isWaitingMakerStart && (
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('earn.alert_waiting_start_title')}</AlertTitle>
<AlertTitle>{/* TODO: i18n */ t('Waiting for maker to start...')}</AlertTitle>
</Alert>
)}
{isWaitingMakerStop && (
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{t('earn.alert_waiting_stop_title')}</AlertTitle>
<AlertTitle>{/* TODO: i18n */ t('Waiting for maker to stop...')}</AlertTitle>
</Alert>
)}
{waitingForOfferUpdate && (
@ -271,33 +265,11 @@ 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 />
{t('earn.button_show_report')}
</Button>
</div>
{jmSession.offer_list && jmSession.offer_list.length > 0 && (
<OfferCard
className="motion-safe:animate-in blur-in"
value={jmSession.offer_list[0]}
nickname={jmSession.nickname}
orderbookStatus={orderbookStatus}
orderbookOffer={currentOrderbookOffer}
fidelityBond={currentOrderbookFidelityBond}
>
<Button type="button" onClick={() => void onStop()} className="w-full" size="lg">
{isWaitingMakerStop ? (
@ -316,23 +288,21 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<Card
className={cn({
hidden: jmSession.maker_running && !waitingForOfferUpdate,
'blur-[2px]':
isWaitingMakerStop ||
waitingForOfferUpdate ||
jmSession.maker_running ||
jmSession.coinjoin_in_process ||
jmSession.rescanning,
'blur-[2px]': isWaitingMakerStop || waitingForOfferUpdate,
})}
>
<CardContent>
<p className="text-muted-foreground mb-4 text-sm">{t('earn.market_explainer')}</p>
{earnBalanceWarning === 'unconfirmed' && <UnconfirmedBalanceAlert />}
{earnBalanceWarning === 'unavailable' && <NoSpendableBalanceAlert />}
<EarnForm
onSubmit={onSubmit}
isWaitingMakerStart={isWaitingMakerStart}
offerMinsizeMax={maxConfirmedJarAvailableBalance}
disabled={
earnPreconditionWarning !== undefined ||
earnBalanceWarning !== null ||
walletInfo.isFetching ||
isWaitingMakerStart ||
isWaitingMakerStop ||
@ -346,9 +316,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</Card>
{/* Fidelity Bonds */}
{!hasFidelityBond ? (
{walletInfo.fidelityBondSummary.fbOutputs.length === 0 ? (
<div
className={cn('mt-8', {
className={cn({
hidden: jmSession.maker_running || waitingForMakerUpdate || waitingForOfferUpdate,
})}
>
@ -361,64 +331,39 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<CardDescription>{t('earn.fidelity_bond.subtitle')}</CardDescription>
<CardAction></CardAction>
</CardHeader>
{!walletInfo.hasEligibleFidelityBondUtxo && (
<CardContent>
<Alert variant="default">
<InfoIcon />
<AlertTitle>{t('earn.fidelity_bond.create_form.alert_no_eligible_utxos_title')}</AlertTitle>
<AlertDescription>
<Trans
i18nKey="earn.fidelity_bond.create_form.alert_no_eligible_utxos_description"
components={{
'1': <Link to={routes.send} className="font-semibold" />,
'3': <Link to={routes.walletJarsDetails} className="font-semibold" />,
}}
/>
</AlertDescription>
</Alert>
</CardContent>
)}
<CardFooter className="gap-2">
<Button
variant="default"
disabled={!isCreateFidelityBondEnabled}
onClick={() => setShowCreateFidelityBondDialog(true)}
>
<Button variant="default" onClick={() => setShowCreateFidelityBondDialog(true)}>
{t('earn.fidelity_bond.create_form.button_create')}
</Button>
</CardFooter>
</Card>
</div>
) : (
<div className="mt-8">
<>
{/* Fidelity Bonds exist */}
<h2 className="my-2 text-xl font-semibold tracking-tight">
{t('earn.title_fidelity_bonds', { count: walletInfo.fidelityBondSummary.fbOutputs.length })}
</h2>
<p className="text-muted-foreground mb-4 text-sm">{t('earn.subtitle_fidelity_bonds')}</p>
<div className="space-y-4">
<div className="flex flex-col gap-2">
{walletInfo.fidelityBondSummary.fbOutputs.map((it) => {
const isExpired = !fb.utxo.isLocked(it)
const actionsEnabled = isExpired && isFidelityBondActionsEnabled
const actionsEnabled =
isExpired &&
!jmSession.rescanning &&
!jmSession.maker_running &&
!waitingForMakerUpdate &&
!waitingForOfferUpdate &&
!walletInfo.isFetching
return (
<FidelityBondCard value={it} key={it.utxo}>
{isExpired && (
<div className="flex w-full flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
onClick={() => setMoveToJarUtxo(it)}
disabled={!actionsEnabled}
>
{actionsEnabled && (
<div className="flex w-full flex-row gap-2">
<Button variant="secondary" className="flex-1" onClick={() => setMoveToJarUtxo(it)}>
<UnlockIcon />
{t('earn.fidelity_bond.existing.button_spend')}
</Button>
<Button
variant="default"
className="flex-1"
onClick={() => setRenewBondUtxo(it)}
disabled={!actionsEnabled}
>
<Button variant="default" className="flex-1" onClick={() => setRenewBondUtxo(it)}>
<RefreshCwIcon />
{t('earn.fidelity_bond.existing.button_renew')}
</Button>
@ -428,21 +373,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
)
})}
</div>
{showCreateAdditionalFidelityBond && (
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
disabled={!isCreateFidelityBondEnabled}
onClick={() => setShowCreateFidelityBondDialog(true)}
>
<PlusIcon />
{t('earn.fidelity_bond.create_form.button_create')}
</Button>
<DevBadge />
</div>
)}
</div>
</>
)}
{/* Fee Configuration Dialog */}
@ -487,11 +418,11 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="overflow-scroll">
<code className="text-destructive">walletInfo.fidelityBondSummary.fbOutputs:</code>
<code className="light:text-red-700 text-red-800">walletInfo.fidelityBondSummary.fbOutputs:</code>
<pre className="text-xs">{JSON.stringify(walletInfo.fidelityBondSummary.fbOutputs, null, 2)}</pre>
</div>
<div className="mt-8 overflow-scroll">
<code className="text-destructive">jmSessionState.offer_list:</code>
<code className="light:text-red-700 text-red-800">jmSessionState.offer_list:</code>
<pre className="text-xs">{JSON.stringify(jmSession.offer_list, null, 2)}</pre>
</div>
</CardContent>

View file

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

View file

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

View file

@ -2,19 +2,18 @@ import { useMemo, type PropsWithChildren } from 'react'
import { ClockIcon, CoinsIcon, CopyIcon } from 'lucide-react'
import { Trans, useTranslation } from 'react-i18next'
import { Card, CardContent, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Balance } from '@/components/ui/jam/Balance'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import { cn, time } from '@/lib/utils'
import { time } from '@/lib/utils'
import { Address } from '../ui/jam/Address'
import { Balance } from '../ui/jam/Balance'
interface FidelityBondCardProps {
value: FidelityBondUtxo
className?: string
}
export function FidelityBondCard({ value, className, children }: PropsWithChildren<FidelityBondCardProps>) {
export function FidelityBondCard({ value, children }: PropsWithChildren<FidelityBondCardProps>) {
const { t, i18n } = useTranslation()
const isExpired = !fb.utxo.isLocked(value)
@ -32,7 +31,7 @@ export function FidelityBondCard({ value, className, children }: PropsWithChildr
}
return (
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
<Card className="w-full">
<CardHeader>
<CardTitle>
{isExpired ? (
@ -43,7 +42,7 @@ export function FidelityBondCard({ value, className, children }: PropsWithChildr
t('earn.fidelity_bond.existing.title_active')
)}
</CardTitle>
<CardDescription className="min-w-0 text-xs break-all">
<CardDescription className="text-xs">
<Tooltip>
<TooltipTrigger asChild>
<code>{value.path}</code>
@ -55,29 +54,29 @@ export function FidelityBondCard({ value, className, children }: PropsWithChildr
</Tooltip>
</CardDescription>
<CardAction>
<div className="flex min-w-0 items-center gap-2">
<CoinsIcon className="shrink-0" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<CoinsIcon />
<div>
<Balance valueString={String(value.value)} />
</div>
</div>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-start gap-4">
<ClockIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-4">
<ClockIcon />
<div className="flex flex-col">
<span className="text-muted-foreground text-sm font-semibold">
{t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
</span>
<span className="text-md block font-mono break-words">
<span className="text-md font-mono">
{value.locktime} ({humanReadableLockDuration})
</span>
</div>
</div>
<div className="flex items-start gap-4">
<CopyIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-4">
<CopyIcon />
<div className="flex flex-col">
<span className="text-muted-foreground text-sm font-semibold">
{t('earn.fidelity_bond.existing.label_address')}
</span>

View file

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

View file

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

View file

@ -4,20 +4,17 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { MoveToJarDialog } from './MoveToJarDialog'
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
type ChildrenProps = { children: ReactNode }
type DialogProps = ChildrenProps & { open?: boolean; onOpenChange?: (open: boolean) => void }
type ValueProps = { value: string }
const h = vi.hoisted(() => ({
fetchAddressQueryData: {
queryReturn: {
data: undefined as { address?: string } | undefined,
isLoading: false,
isError: false,
},
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } }),
isPending: false,
refetch: vi.fn(),
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
@ -30,16 +27,13 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => h.fetchAddressQueryData,
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => await options.mutationFn(input),
})),
useQuery: () => h.queryReturn,
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
@ -70,29 +64,10 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
utxos: [],
},
],
walletBalanceSummary: { calculatedTotalBalanceInSats: 6000 },
refetch: h.refetch,
}),
}))
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onSelect,
disabled,
isSelected,
}: {
name?: string
onSelect?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
@ -118,10 +93,6 @@ vi.mock('@/components/ui/jam/Address', () => ({
Address: ({ value }: ValueProps) => <div data-testid="address">{value}</div>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
}))
vi.mock('@/components/ui/jam/CopyButton', () => ({
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
}))
@ -147,14 +118,13 @@ const renderDialog = (props?: { utxo?: FidelityBondUtxo; onOpenChange?: (open: b
const goToConfirm = () => {
fireEvent.click(screen.getByText('Jar 1'))
fireEvent.click(screen.getByText('global.next'))
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
}
describe('MoveToJarDialog', () => {
beforeEach(() => {
h.fetchAddressQueryData = { data: { address: 'bc111' }, isLoading: false, isError: false }
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
h.freezeAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({})
h.queryReturn = { data: { address: 'bc111' }, isLoading: false, isError: false }
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'movetxid99' } })
h.isPending = false
h.refetch = vi.fn()
h.extraUtxos = []
@ -164,35 +134,35 @@ describe('MoveToJarDialog', () => {
renderDialog()
expect(screen.getByText('earn.fidelity_bond.move.title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.move.select_jar.description')).toBeInTheDocument()
expect(screen.getByText('Jar 0')).toBeEnabled()
expect(screen.getByText('Jar 1')).toBeInTheDocument()
})
it('moves to the confirm step and shows the destination address', () => {
renderDialog()
goToConfirm()
expect(screen.getByText('Jar 1')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_jar_n {"jar":1}')).toBeInTheDocument()
expect(screen.getByTestId('address')).toHaveTextContent('bc111')
})
it('shows a loading indicator while the destination address loads', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false }
h.queryReturn = { data: undefined, isLoading: true, isError: false }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.move.text_loading')).toBeInTheDocument()
})
it('shows an error alert when the address query fails', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
h.queryReturn = { data: undefined, isLoading: false, isError: true }
renderDialog()
goToConfirm()
expect(screen.getByText('global.errors.error_loading_address_failed')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.move.error_loading_address')).toBeInTheDocument()
})
it('completes the move flow and reaches the success step', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
@ -204,17 +174,19 @@ describe('MoveToJarDialog', () => {
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
renderDialog({ utxo: frozenUtxo })
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
const calls = h.freezeAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
})
it('returns to the confirm step when sending fails', async () => {
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() =>

View file

@ -1,39 +1,48 @@
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 { useState, useMemo } from 'react'
import {
directsendMutation,
freezeMutation,
getaddressOptions,
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
AlertTriangleIcon,
CheckCircle2Icon,
ChevronLeftIcon,
CopyIcon,
CheckIcon,
UnlockIcon,
Loader2Icon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertTitle } from '@/components/ui/alert'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { buttonVariants } from '@/components/ui/button-variants'
import { Card, CardContent } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CopyButton } from '@/components/ui/jam/CopyButton'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import type { WalletFileName } from '@/lib/utils'
import { createMoveToJarFormSchema, type MoveToJarFormValues } from './MoveToJarDialog.schema'
import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout'
import {
AddressPreview,
CopyableField,
FidelityBondAmount,
InfoCard,
InlineLoading,
JarBadge,
PendingStep,
StepIntro,
SuccessHeading,
} from './fidelity-bond/FidelityBondDialogParts'
import { FidelityBondJarSelector } from './fidelity-bond/FidelityBondJarSelector'
import { WizardStepFooter } from './fidelity-bond/WizardStepFooter'
import { useFidelityBondSweep } from './fidelity-bond/useFidelityBondSweep'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { getErrorReason } from '@/lib/errorReason'
import { cn, formatSats, type WalletFileName } from '@/lib/utils'
import type { JarIndex } from '@/types/global'
import { Address } from '../ui/jam/Address'
type Step = 'select_jar' | 'confirm' | 'sending' | 'success'
const WIZARD_STEPS: Step[] = ['select_jar', 'confirm']
interface MoveToJarDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@ -47,33 +56,23 @@ 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 [confirmationChecked, setConfirmationChecked] = useState(false)
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const [error, setError] = useState<string | 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 sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
walletFileName,
utxo,
unfreezeErrorKey: 'earn.fidelity_bond.move.error_unfreezing_fidelity_bond',
sendErrorKey: 'earn.fidelity_bond.move.error_spending_fidelity_bond',
})
// All jars except the FB's source jar are valid destinations
const destinationJars = useMemo(() => {
return walletInfo.jars.filter((jar) => jar.jarIndex !== utxo.mixdepth)
}, [walletInfo.jars, utxo.mixdepth])
const destinationJar = walletInfo.jars.find((jar) => jar.jarIndex === selectedJarIndex)
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
const utxosToFreeze = useMemo(() => {
if (!sourceJar) return []
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
}, [sourceJar, utxo.utxo])
const getAddressQueryOptions = getaddressOptions({
client,
@ -90,12 +89,40 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
retry: false,
})
if (getAddressQuery.isError && !error) {
setError(t('earn.fidelity_bond.move.error_loading_address'))
}
const destinationAddress = getAddressQuery.data?.address
const displayError = error ?? (getAddressQuery.isError ? t('global.errors.error_loading_address_failed') : undefined)
const freezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.move.error_freezing_utxos')} ${reason}`)
},
})
const unfreezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.move.error_unfreezing_fidelity_bond')} ${reason}`)
},
})
const directSend = useMutation({
...directsendMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.move.error_spending_fidelity_bond')} ${reason}`)
},
})
const handleReset = () => {
setStep('select_jar')
reset()
setSelectedJarIndex(undefined)
setConfirmationChecked(false)
setTxResult(undefined)
setError(undefined)
}
@ -107,131 +134,293 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo
onOpenChange(newOpen)
}
const submitMove = handleSubmit(async () => {
if (!destinationAddress) return
const handleSubmit = async () => {
if (selectedJarIndex === undefined || !destinationAddress) return
setStep('sending')
const txResult = await sweep({
destination: destinationAddress,
tryFreezeAfterBroadcast: false,
})
setError(undefined)
setTxResult(txResult)
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)
}
if (txResult) {
if (utxo.frozen) {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
}
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: utxo.mixdepth,
amount_sats: 0,
destination: destinationAddress,
},
})
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.move.success_text'))
} else {
setStep('confirm')
}
})
const renderFooter = () => {
switch (step) {
case 'select_jar':
return (
<WizardStepFooter
onCancel={() => handleOpenChange(false)}
onPrimary={() => setStep('confirm')}
primaryDisabled={selectedJarIndex === undefined}
primaryLabel={t('global.next')}
/>
)
case 'confirm':
return (
<WizardStepFooter
onBack={() => setStep('select_jar')}
onCancel={() => handleOpenChange(false)}
onPrimary={() => void submitMove()}
primaryDisabled={!destinationAddress}
isLoading={isLoading || isSubmitting}
primaryLabel={t('earn.fidelity_bond.move.text_button_submit')}
/>
)
case 'sending':
return null
case 'success':
return <Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
// 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 },
})
} catch {
// logged via onError
}
}
await walletInfo.refetch()
} 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
}
}
setStep('confirm')
}
}
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
return (
<FidelityBondDialogLayout
open={open}
onOpenChange={handleOpenChange}
title={t('earn.fidelity_bond.move.title')}
currentStep={step === 'sending' || step === 'success' ? undefined : WIZARD_STEPS.indexOf(step)}
totalSteps={WIZARD_STEPS.length}
error={displayError}
footer={renderFooter()}
>
{step === 'select_jar' && (
<div className="space-y-6">
<StepIntro icon={UnlockIcon} title={t('earn.fidelity_bond.move.select_jar.description')} />
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.move.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
</DialogHeader>
<InfoCard label={t('earn.fidelity_bond.move.label_amount')}>
<FidelityBondAmount value={utxo.value} className="text-lg" />
</InfoCard>
<FidelityBondJarSelector
selectedJarIndex={selectedJarIndex}
onSelect={(jarIndex) =>
setValue('destinationJarIndex', jarIndex, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
isJarDisabled={() => false}
/>
</div>
)}
{step === 'confirm' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
<JarBadge jarIndex={utxo.mixdepth} name={sourceJar?.name} />
</InfoCard>
<InfoCard label={t('earn.fidelity_bond.move.label_destination')}>
<JarBadge jarIndex={selectedJarIndex} name={destinationJar?.name} />
</InfoCard>
</div>
<InfoCard highlight label={t('earn.fidelity_bond.move.label_amount')}>
<FidelityBondAmount value={utxo.value} />
</InfoCard>
{getAddressQuery.isLoading ? (
<InlineLoading text={t('earn.fidelity_bond.move.text_loading')} />
) : (
destinationAddress && (
<AddressPreview label={t('earn.fidelity_bond.move.label_destination')} address={destinationAddress} />
)
)}
<Alert variant="warning">
{error && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>{t('earn.fidelity_bond.move.confirm_send_modal.title')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)}
)}
{step === 'sending' && <PendingStep icon={UnlockIcon} title={t('earn.fidelity_bond.move.text_sending')} />}
<div className="py-2">
{step === 'select_jar' && (
<div className="space-y-6">
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<UnlockIcon className="text-primary h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-medium">{t('earn.fidelity_bond.move.select_jar.description')}</p>
</div>
</div>
{step === 'success' && (
<div className="space-y-6">
<SuccessHeading title={t('earn.fidelity_bond.move.success_text')} />
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_amount')}
</p>
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
</div>
{txResult?.txinfo?.txid && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
value={txResult.txinfo.txid}
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
/>
<div className="grid gap-3">
{destinationJars.map((jar) => {
const isSelected = selectedJarIndex === jar.jarIndex
return (
<Card
key={jar.jarIndex}
className={cn(
'cursor-pointer transition-all duration-200 hover:shadow-md',
isSelected ? 'border-primary bg-primary/5 shadow-sm' : 'hover:bg-muted/30',
)}
onClick={() => setSelectedJarIndex(jar.jarIndex)}
>
<CardContent className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div
className="h-10 w-10 rounded-full shadow-sm"
style={{ backgroundColor: jar.color, opacity: 0.8 }}
/>
<div>
<p className="font-semibold">{jar.name}</p>
</div>
</div>
<div className="text-right">
<p className="font-mono font-semibold">
{formatSats(jar.balanceSummary.calculatedAvailableBalanceInSats)}
</p>
{isSelected && <CheckCircle2Icon className="text-primary mt-1 ml-auto h-5 w-5" />}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
)}
{step === 'confirm' && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_jar')}
</p>
<p className="font-semibold">
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: utxo.mixdepth })}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">{t('earn.fidelity_bond.move.label_destination')}</p>
<p className="font-semibold">
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: selectedJarIndex })}
</p>
</div>
</div>
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_amount')}
</p>
<p className="font-mono text-2xl font-bold">{formatSats(utxo.value)}</p>
</div>
{getAddressQuery.isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2Icon className="text-primary h-6 w-6 animate-spin" />
<p className="text-muted-foreground ml-2">{t('earn.fidelity_bond.move.text_loading')}</p>
</div>
) : (
destinationAddress && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('earn.fidelity_bond.move.label_destination')}</Label>
<div className="bg-muted rounded-lg p-3">
<Address className="text-xs" value={destinationAddress} />
</div>
</div>
)
)}
<Alert variant="warning">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>{t('earn.fidelity_bond.move.confirm_send_modal.title')}</AlertTitle>
</Alert>
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
<Switch
id="move-confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) => setConfirmationChecked(checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label htmlFor="move-confirmation" className="cursor-pointer text-sm font-medium">
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
</Label>
</div>
</div>
</div>
)}
{step === 'sending' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="relative">
<Spinner className="h-16 w-16" />
<div className="absolute inset-0 flex items-center justify-center">
<UnlockIcon className="text-primary h-6 w-6 animate-pulse" />
</div>
</div>
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.move.text_sending')}</p>
</div>
)}
{step === 'success' && (
<div className="space-y-6">
<div className="flex flex-col items-center py-6">
<div className="rounded-full bg-green-500/10 p-4">
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
</div>
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.move.success_text')}</p>
</div>
{txResult?.txinfo?.txid && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
{txResult.txinfo.txid}
</code>
<CopyButton
key="copy-txid-move"
value={txResult.txinfo.txid}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() =>
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
}
onError={() => toast.error(t('global.errors.reason_unknown'))}
/>
</div>
</div>
)}
</div>
)}
</div>
)}
</FidelityBondDialogLayout>
{step === 'select_jar' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.move.text_button_cancel')}
</Button>
<Button className="min-w-32" disabled={selectedJarIndex === undefined} onClick={() => setStep('confirm')}>
{t('earn.fidelity_bond.select_date.text_primary_button')}
</Button>
</DialogFooter>
)}
{step === 'confirm' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="ghost" onClick={() => setStep('select_jar')} disabled={isLoading}>
<ChevronLeftIcon className="mr-1 h-4 w-4" />
{t('global.back')}
</Button>
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
{t('earn.fidelity_bond.move.text_button_cancel')}
</Button>
<Button
className="min-w-32"
disabled={!confirmationChecked || !destinationAddress || isLoading}
onClick={() => void handleSubmit()}
>
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{t('earn.fidelity_bond.move.text_button_submit')}
</Button>
</DialogFooter>
)}
{step === 'success' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.move.text_button_done')}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -4,15 +4,13 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import { RenewBondDialog } from './RenewBondDialog'
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
type ChildrenProps = { children?: ReactNode }
type ValueProps = { value: string }
const h = vi.hoisted(() => ({
fetchAddressQueryData: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
directSendAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
freezeAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({}),
queryReturn: { data: undefined as { address: string } | undefined, isLoading: false, isError: false },
mutateAsync: vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } }),
isPending: false,
refetch: vi.fn(),
utxoFrozen: false,
extraUtxos: [] as Array<{ utxo: string; value: number; frozen: boolean }>,
@ -26,16 +24,13 @@ vi.mock('react-i18next', () => ({
}))
vi.mock('@tanstack/react-query', () => ({
useQuery: () => h.fetchAddressQueryData,
useMutation: vi.fn((options: MutationOptions) => ({
isPending: false,
mutateAsync: async (input: unknown) => await options.mutationFn(input),
})),
useQuery: () => h.queryReturn,
useMutation: () => ({ mutateAsync: h.mutateAsync, isPending: h.isPending }),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
@ -61,7 +56,7 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
vi.mock('@/lib/errorReason', () => ({ getErrorReason: () => 'reason' }))
vi.mock('@/lib/fidelityBondUtils', () => ({
lockdate: { toDateLabel: () => 'January 1, 2030' },
lockdate: { toTimestamp: () => 1_893_456_000_000 },
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
@ -141,10 +136,6 @@ vi.mock('@/components/ui/jam/CopyButton', () => ({
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
}))
vi.mock('@/components/ui/label', () => ({ Label: ({ children }: ChildrenProps) => <label>{children}</label> }))
vi.mock('../ui/jam/Address', () => ({
@ -175,15 +166,15 @@ const selectYear = (value: string) => {
const goToConfirm = () => {
selectMonth('06')
fireEvent.click(screen.getByText('global.next'))
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
}
describe('RenewBondDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
h.fetchAddressQueryData = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
h.freezeAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({})
h.queryReturn = { data: { address: 'bc1timelock' }, isLoading: false, isError: false }
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockResolvedValue({ txinfo: { txid: 'abcd1234' } })
h.isPending = false
h.utxoFrozen = false
h.extraUtxos = []
h.selectHandlers = []
@ -198,13 +189,13 @@ describe('RenewBondDialog', () => {
it('selecting a month enables the next button and shows the chosen date', () => {
renderDialog()
selectMonth('06')
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
})
it('selecting a year computes a clamped lock date', () => {
renderDialog()
selectYear('2027')
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
})
it('selecting an early month bumps the year forward', () => {
@ -212,7 +203,7 @@ describe('RenewBondDialog', () => {
// a month earlier than the minimum month forces year = minYear + 1
selectMonth('06')
selectYear('2026')
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
})
it('moves to the confirm step and shows the timelock address', () => {
@ -222,33 +213,27 @@ describe('RenewBondDialog', () => {
})
it('shows a loading spinner while the timelock address loads', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: true, isError: false }
h.queryReturn = { data: undefined, isLoading: true, isError: false }
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.renew.text_loading')).toBeInTheDocument()
})
it('shows an error alert when the address query fails', () => {
h.fetchAddressQueryData = { data: undefined, isLoading: false, isError: true }
h.queryReturn = { data: undefined, isLoading: false, isError: true }
renderDialog()
expect(screen.getByText('earn.fidelity_bond.error_loading_address')).toBeInTheDocument()
})
it('completes the renew flow and reaches the success step', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
renderDialog()
expect(screen.queryByText('earn.fidelity_bond.renew.success_text')).not.toBeInTheDocument()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
expect(screen.getAllByText('abcd1234').length).toBeGreaterThan(0)
expect(h.refetch).toHaveBeenCalled()
})
@ -260,40 +245,13 @@ describe('RenewBondDialog', () => {
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
const calls = h.freezeAsync.mock.calls as unknown as Array<
[{ body?: { 'utxo-string'?: string; freeze?: boolean } }]
>
expect(
calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false),
).toBe(true)
})
it('freezes a fidelity bond after sending', async () => {
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
render(<RenewBondDialog open onOpenChange={vi.fn()} walletFileName={'test.jmdat'} utxo={frozenUtxo} />)
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.renew.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.renew.success_text')).toBeInTheDocument())
const calls = h.freezeAsync.mock.calls as unknown as Array<
[{ body?: { 'utxo-string'?: string; freeze?: boolean } }]
>
// unfreezes the expired fb utxo
expect(
calls.some((call) => call[0]?.body?.['utxo-string'] === frozenUtxo.utxo && call[0]?.body?.freeze === false),
).toBe(true)
// freezes the renewed fb utxo
expect(
calls.some((call) => call[0]?.body?.['utxo-string'] === 'abcd1234:0' && call[0]?.body?.freeze === true),
).toBe(true)
const calls = h.mutateAsync.mock.calls as unknown as Array<[{ body?: { freeze?: boolean } }]>
expect(calls.some((call) => call[0]?.body?.freeze === false)).toBe(true)
})
it('returns to the confirm step when sending fails', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
h.directSendAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
h.mutateAsync = vi.fn<() => Promise<unknown>>().mockRejectedValue(new Error('send failed'))
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))

View file

@ -1,41 +1,50 @@
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 { useState, useMemo } from 'react'
import {
directsendMutation,
freezeMutation,
gettimelockaddressOptions,
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
AlertTriangleIcon,
CalendarIcon,
CheckCircle2Icon,
CheckIcon,
ChevronLeftIcon,
CopyIcon,
RefreshCwIcon,
} from 'lucide-react'
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 { buttonVariants } from '@/components/ui/button-variants'
import {
AddressPreview,
ConfirmationToggle,
CopyableField,
FidelityBondAmount,
InfoCard,
InlineLoading,
JarBadge,
PendingStep,
StepIntro,
SuccessHeading,
} from './fidelity-bond/FidelityBondDialogParts'
import { LockdateSelect } from './fidelity-bond/LockdateSelect'
import { WizardStepFooter } from './fidelity-bond/WizardStepFooter'
import { useFidelityBondSweep } from './fidelity-bond/useFidelityBondSweep'
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CopyButton } from '@/components/ui/jam/CopyButton'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import { cn, formatSats, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { Address } from '../ui/jam/Address'
import { generateLockdateOptions, getYearOptions, getMonthOptions } from './CreateFidelityBondDialog/types'
type Step = 'select_date' | 'confirm' | 'sending' | 'success'
const WIZARD_STEPS: Step[] = ['select_date', 'confirm']
interface RenewBondDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@ -46,35 +55,46 @@ interface RenewBondDialogProps {
export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: RenewBondDialogProps) {
const { t } = useTranslation()
const client = useApiClient()
const walletInfo = useJamWalletInfoContext()
const { enabled: isDeveloperMode } = useDeveloperMode()
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 [error, setError] = useState<string | 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 lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
const monthOptions = useMemo(() => getMonthOptions(), [])
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
walletFileName,
utxo,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
})
const minLockdate = lockdateOptions.at(0)?.value ?? ''
const maxLockdate = lockdateOptions.at(-1)?.value ?? ''
const clampLockdate = (lockdate: string): fb.Lockdate | '' => {
if (!lockdate || lockdate < minLockdate) return minLockdate || ''
if (lockdate > maxLockdate) return maxLockdate || ''
return lockdate as fb.Lockdate
}
const selectedYear = selectedLockdate ? selectedLockdate.slice(0, 4) : ''
const selectedMonth = selectedLockdate ? selectedLockdate.slice(5, 7) : ''
const minYear = minLockdate ? Number.parseInt(minLockdate.slice(0, 4), 10) : 0
const minMonth = minLockdate ? Number.parseInt(minLockdate.slice(5, 7), 10) : 1
const selectedDateLabel = selectedLockdate ? fb.lockdate.toDateLabel(selectedLockdate) : null
const selectedDateLabel = selectedLockdate
? new Date(fb.lockdate.toTimestamp(selectedLockdate)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: null
const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
const utxosToFreeze = useMemo(() => {
if (!sourceJar) return []
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
}, [sourceJar, utxo.utxo])
const timelockAddressQuery = useQuery({
...gettimelockaddressOptions({
@ -89,13 +109,40 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
retry: false,
})
if (timelockAddressQuery.isError && !error) {
setError(t('earn.fidelity_bond.error_loading_address'))
}
const destinationAddress = timelockAddressQuery.data?.address
const displayError =
error ?? (timelockAddressQuery.isError ? t('earn.fidelity_bond.error_loading_address') : undefined)
const freezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_freezing_utxos')} ${reason}`)
},
})
const unfreezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_unfreezing_utxos')} ${reason}`)
},
})
const directSend = useMutation({
...directsendMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.renew.error_renewing_fidelity_bond')} ${reason}`)
},
})
const handleReset = () => {
setStep('select_date')
reset(RENEW_BOND_FORM_DEFAULT_VALUES)
setSelectedLockdate('')
setConfirmationChecked(false)
setTxResult(undefined)
setError(undefined)
}
@ -107,161 +154,348 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
onOpenChange(newOpen)
}
const submitRenewal = handleSubmit(async () => {
const handleSubmit = async () => {
if (!destinationAddress) return
setStep('sending')
setError(undefined)
const txResult = await sweep({
destination: destinationAddress,
tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
})
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)
}
setTxResult(txResult)
if (txResult) {
if (utxo.frozen) {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
}
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: utxo.mixdepth,
amount_sats: 0,
destination: destinationAddress,
},
})
setTxResult(result)
setStep('success')
toast.success(t('earn.fidelity_bond.renew.success_text'))
} else {
setStep('confirm')
}
})
const renderFooter = () => {
switch (step) {
case 'select_date':
return (
<WizardStepFooter
onCancel={() => handleOpenChange(false)}
onPrimary={() => setStep('confirm')}
primaryDisabled={!selectedLockdate}
primaryLabel={t('global.next')}
/>
)
case 'confirm':
return (
<WizardStepFooter
onBack={() => setStep('select_date')}
onCancel={() => handleOpenChange(false)}
onPrimary={() => void submitRenewal()}
primaryDisabled={!confirmationChecked || !destinationAddress}
isLoading={isLoading || isSubmitting}
primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')}
/>
)
case 'sending':
return null
case 'success':
return <Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
// 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 },
})
} catch {
// logged via onError
}
}
await walletInfo.refetch()
} 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
}
}
setStep('confirm')
}
}
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
return (
<FidelityBondDialogLayout
open={open}
onOpenChange={handleOpenChange}
title={t('earn.fidelity_bond.renew.title')}
currentStep={step === 'sending' || step === 'success' ? undefined : WIZARD_STEPS.indexOf(step)}
totalSteps={WIZARD_STEPS.length}
error={displayError}
footer={renderFooter()}
>
{step === 'select_date' && (
<div className="space-y-6">
<StepIntro icon={CalendarIcon} title={t('earn.fidelity_bond.select_date.description')} />
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.renew.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
</DialogHeader>
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_amount')}>
<FidelityBondAmount value={utxo.value} className="text-lg" />
</InfoCard>
<LockdateSelect
id="renew-lockdate"
value={selectedLockdate ?? ''}
onChange={(lockdate) =>
setValue('lockdate', lockdate || undefined, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
</div>
)}
{step === 'confirm' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_lock_date')}>
<p className="font-semibold">{selectedDateLabel}</p>
</InfoCard>
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
<JarBadge jarIndex={utxo.mixdepth} name={sourceJar?.name} />
</InfoCard>
</div>
<InfoCard highlight label={t('earn.fidelity_bond.review_inputs.label_amount')}>
<FidelityBondAmount value={utxo.value} />
</InfoCard>
{timelockAddressQuery.isLoading ? (
<InlineLoading text={t('earn.fidelity_bond.renew.text_loading')} />
) : (
destinationAddress && (
<AddressPreview
label={t('earn.fidelity_bond.review_inputs.label_address')}
address={destinationAddress}
/>
)
)}
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.fidelity_bond.renew.confirm_send_modal.title')}</AlertTitle>
<AlertDescription>
{t('earn.fidelity_bond.confirm_modal.body', {
/* TODO: fix human readable duration */
humanReadableDuration: selectedDateLabel ? `until ${selectedDateLabel}` : '',
date: selectedDateLabel || '',
})}
</AlertDescription>
{error && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<ConfirmationToggle
id="renew-confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) =>
setValue('confirmationAccepted', checked, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
</div>
)}
<div className="py-2">
{step === 'select_date' && (
<div className="space-y-6">
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<CalendarIcon className="text-primary h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-medium">{t('earn.fidelity_bond.select_date.description')}</p>
</div>
</div>
{step === 'sending' && <PendingStep icon={RefreshCwIcon} title={t('earn.fidelity_bond.renew.text_sending')} />}
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_amount')}
</p>
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
</div>
{step === 'success' && (
<div className="space-y-6">
<SuccessHeading title={t('earn.fidelity_bond.renew.success_text')} />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="renew-lockdate-month" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_month')}
</Label>
<Select
value={selectedMonth}
onValueChange={(month) => {
const year =
selectedYear || String(Number.parseInt(month, 10) >= minMonth ? minYear : minYear + 1)
const newLockdate = clampLockdate(`${year}-${month}`)
setSelectedLockdate(newLockdate)
}}
>
<SelectTrigger id="renew-lockdate-month" className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
</SelectTrigger>
<SelectContent>
{monthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="renew-lockdate-year" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_year')}
</Label>
<Select
value={selectedYear}
onValueChange={(year) => {
const month = selectedMonth || String(year === String(minYear) ? minMonth : 1).padStart(2, '0')
const newLockdate = clampLockdate(`${year}-${month}`)
setSelectedLockdate(newLockdate)
}}
>
<SelectTrigger id="renew-lockdate-year" className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
</SelectTrigger>
<SelectContent>
{yearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{destinationAddress && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
value={destinationAddress}
copiedMessage={t('receive.text_copy_address')}
/>
{selectedLockdate && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<p className="text-muted-foreground text-sm">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
</p>
<p className="mt-1 text-lg font-semibold">{selectedDateLabel}</p>
</div>
)}
</div>
)}
{txResult?.txinfo?.txid && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
value={txResult.txinfo.txid}
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
/>
{step === 'confirm' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
</p>
<p className="font-semibold">{selectedDateLabel}</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">{t('earn.fidelity_bond.review_inputs.label_jar')}</p>
<p className="font-semibold">
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: utxo.mixdepth })}
</p>
</div>
</div>
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{t('earn.fidelity_bond.review_inputs.label_amount')}</p>
<p className="font-mono text-2xl font-bold">{formatSats(utxo.value)}</p>
</div>
{timelockAddressQuery.isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-4">
<Spinner className="motion-reduce:hidden" />
{t('earn.fidelity_bond.renew.text_loading')}
</div>
) : (
destinationAddress && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('earn.fidelity_bond.review_inputs.label_address')}</Label>
<div className="bg-muted rounded-lg p-3">
<Address className="text-xs" value={destinationAddress} />
</div>
</div>
)
)}
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.fidelity_bond.renew.confirm_send_modal.title')}</AlertTitle>
<AlertDescription>
{t('earn.fidelity_bond.confirm_modal.body', {
/* TODO: fix human readable duration */
humanReadableDuration: selectedDateLabel ? `until ${selectedDateLabel}` : '',
date: selectedDateLabel || '',
})}
</AlertDescription>
</Alert>
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
<Switch
id="renew-confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) => setConfirmationChecked(checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label htmlFor="renew-confirmation" className="cursor-pointer text-sm font-medium">
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
</Label>
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
</p>
</div>
</div>
</div>
)}
{step === 'sending' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="relative">
<Spinner className="h-16 w-16" />
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCwIcon className="text-primary h-6 w-6 animate-pulse" />
</div>
</div>
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.renew.text_sending')}</p>
</div>
)}
{step === 'success' && (
<div className="space-y-6">
<div className="flex flex-col items-center py-6">
<div className="rounded-full bg-green-500/10 p-4">
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
</div>
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.renew.success_text')}</p>
</div>
{destinationAddress && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_address')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
{destinationAddress}
</code>
<CopyButton
key="copy-address-renew"
value={destinationAddress}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() => toast.success(t('receive.text_copy_address'))}
onError={() => toast.error(t('global.errors.reason_unknown'))}
/>
</div>
</div>
)}
{txResult?.txinfo?.txid && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
{txResult.txinfo.txid}
</code>
<CopyButton
key="copy-txid-renew"
value={txResult.txinfo.txid}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() =>
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
}
onError={() => toast.error(t('global.errors.reason_unknown'))}
/>
</div>
</div>
)}
</div>
)}
</div>
)}
</FidelityBondDialogLayout>
{step === 'select_date' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.select_date.text_secondary_button')}
</Button>
<Button className="min-w-32" disabled={!selectedLockdate} onClick={() => setStep('confirm')}>
{t('earn.fidelity_bond.select_date.text_primary_button')}
</Button>
</DialogFooter>
)}
{step === 'confirm' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="ghost" onClick={() => setStep('select_date')} disabled={isLoading}>
<ChevronLeftIcon className="mr-1 h-4 w-4" />
{t('global.back')}
</Button>
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
{t('earn.fidelity_bond.select_date.text_secondary_button')}
</Button>
<Button
className="min-w-32"
disabled={!confirmationChecked || !destinationAddress || isLoading}
onClick={() => void handleSubmit()}
>
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{t('earn.fidelity_bond.renew.text_button_submit')}
</Button>
</DialogFooter>
)}
{step === 'success' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.create_fidelity_bond.text_primary_button')}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -1,59 +0,0 @@
import type { ComponentProps, ReactNode } from 'react'
import { AlertTriangleIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { StepProgress } from './StepProgress'
type FidelityBondDialogLayoutProps = Omit<ComponentProps<typeof Dialog>, 'children'> & {
title: string
/** zero-based wizard step, or undefined to hide the progress indicator (pending/success states) */
currentStep?: number
totalSteps: number
error?: string
footer: ReactNode
children: ReactNode
}
export function FidelityBondDialogLayout({
title,
currentStep,
totalSteps,
error,
footer,
children,
...dialogProps
}: FidelityBondDialogLayoutProps) {
const { t } = useTranslation()
return (
<Dialog {...dialogProps}>
<DialogContent className="flex max-h-[90dvh] flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="text-2xl">{title}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
</DialogHeader>
{currentStep !== undefined && <StepProgress currentStep={currentStep} totalSteps={totalSteps} />}
{error && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* negative margin + matching padding keep the scrollbar in the dialog padding, clear of content */}
<div className="-mr-4 min-h-0 flex-1 overflow-y-auto py-2 pr-4 sm:-mr-6 sm:pr-6">{children}</div>
{footer && <DialogFooter className="gap-3 sm:gap-2">{footer}</DialogFooter>}
</DialogContent>
</Dialog>
)
}

View file

@ -1,176 +0,0 @@
import type { ReactNode } from 'react'
import { CheckCircle2Icon, CheckIcon, CopyIcon, type LucideIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { jarBadgeVariant } from '@/components/ui/badge-variants'
import { buttonVariants } from '@/components/ui/button-variants'
import { Item, ItemTitle, ItemDescription, ItemContent, ItemMedia } from '@/components/ui/item'
import { Address } from '@/components/ui/jam/Address'
import { Balance } from '@/components/ui/jam/Balance'
import { CopyButton } from '@/components/ui/jam/CopyButton'
import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import type { AmountSats, JarIndex } from '@/types/global'
/** icon + title/subtitle block shown at the top of a wizard step */
export function StepIntro({ icon: Icon, title, subtitle }: { icon: LucideIcon; title: string; subtitle?: string }) {
return (
<Item className="bg-muted/50 rounded-lg">
<ItemMedia variant="icon" className="bg-primary/10 rounded-lg">
<Icon className="text-primary h-5 w-5" />
</ItemMedia>
<ItemContent>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{subtitle}</ItemDescription>
</ItemContent>
</Item>
)
}
/** labeled summary card used on review/confirm steps */
export function InfoCard({
label,
highlight = false,
children,
}: {
label: string
highlight?: boolean
children: ReactNode
}) {
return (
<div className={cn('rounded-lg p-4', highlight ? 'bg-primary/5 border-primary/20 border' : 'bg-muted/50')}>
<p className="text-muted-foreground mb-1 text-xs">{label}</p>
{children}
</div>
)
}
export function FidelityBondAmount({ value, className }: { value: AmountSats; className?: string }) {
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 (
<Badge variant={jarBadgeVariant(jarIndex)}>
{name ? (
<>
{name} <span>#{jarIndex}</span>
</>
) : (
/* TODO: i18n - move key to different namespace */
t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: jarIndex })
)}
</Badge>
)
}
export function ConfirmationToggle({
id,
checked,
onCheckedChange,
}: {
id: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const { t } = useTranslation()
return (
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
<div className="grid gap-1.5 leading-none">
<Label htmlFor={id} className="cursor-pointer text-sm font-medium">
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
</Label>
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
</p>
</div>
</div>
)
}
/** labeled value with a copy-to-clipboard button, used on success steps */
export function CopyableField({
label,
value,
copiedMessage,
}: {
label: string
value: string
copiedMessage: string
}) {
const { t } = useTranslation()
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">{value}</code>
<CopyButton
value={value}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="text-brand-success h-4 w-4" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() => toast.success(copiedMessage)}
onError={(error) => {
const reason = (error instanceof Error ? error.message : undefined) || t('global.errors.reason_unknown')
toast.error(t('global.errors.error_copy_to_clipboard_failed', { reason }))
}}
/>
</div>
</div>
)
}
/** labeled read-only address preview, used on confirm steps */
export function AddressPreview({ label, address }: { label: string; address: string }) {
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{label}</Label>
<div className="bg-muted rounded-lg p-3">
<Address className="text-xs" value={address} />
</div>
</div>
)
}
export function InlineLoading({ text }: { text: string }) {
return (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-4">
<Spinner className="motion-reduce:hidden" />
{text}
</div>
)
}
/** full-step spinner shown while a transaction is in flight */
export function PendingStep({ icon: Icon, title, subtitle }: { icon: LucideIcon; title: string; subtitle?: string }) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="relative">
<Spinner className="h-16 w-16" />
<div className="absolute inset-0 flex items-center justify-center">
<Icon className="text-primary h-6 w-6 animate-pulse" />
</div>
</div>
<p className="mt-6 text-lg font-semibold">{title}</p>
{subtitle && <p className="text-muted-foreground mt-2 text-sm">{subtitle}</p>}
</div>
)
}
export function SuccessHeading({ title, subtitle }: { title: string; subtitle?: string }) {
return (
<div className="flex flex-col items-center py-6">
<div className="bg-brand-success/10 rounded-full p-4">
<CheckCircle2Icon className="text-brand-success h-16 w-16" />
</div>
<p className="mt-4 text-xl font-bold">{title}</p>
{subtitle && <p className="text-muted-foreground mt-2 text-sm">{subtitle}</p>}
</div>
)
}

View file

@ -1,33 +0,0 @@
import { SelectableJar } from '@/components/ui/jam/SelectableJar'
import { useJamWalletInfoContext, type Jar } from '@/context/JamWalletInfoContext'
import type { JarIndex } from '@/types/global'
type FidelityBondJarSelectorProps = {
selectedJarIndex: JarIndex | undefined
onSelect: (jarIndex: JarIndex) => void
isJarDisabled: (jar: Jar) => boolean
}
/** jar selection grid shared by the fidelity bond flows, same look as the send flow's jar selector */
export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisabled }: FidelityBondJarSelectorProps) {
const { jars, walletBalanceSummary } = useJamWalletInfoContext()
return (
<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={() => onSelect(jar.jarIndex)}
disabled={isJarDisabled(jar)}
/>
))}
</div>
)
}

View file

@ -1,114 +0,0 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import * as fb from '@/lib/fidelityBondUtils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { generateLockdateOptions, getMonthOptions, getYearOptions } from '../CreateFidelityBondDialog/types'
type LockdateSelectProps = {
/** prefix for the month/year element ids */
id: string
value: fb.Lockdate | ''
onChange: (value: fb.Lockdate | '') => void
unavailableValues?: readonly fb.Lockdate[]
}
export function LockdateSelect({ id, value, onChange, unavailableValues = [] }: LockdateSelectProps) {
const { t } = useTranslation()
const { enabled: isDeveloperMode } = useDeveloperMode()
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
const unavailableSet = useMemo(() => new Set(unavailableValues), [unavailableValues])
const availableLockdateOptions = useMemo(
() => lockdateOptions.filter((option) => !unavailableSet.has(option.value)),
[lockdateOptions, unavailableSet],
)
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
const monthOptions = useMemo(() => getMonthOptions(), [])
const selectedYear = value ? value.slice(0, 4) : ''
const selectedMonth = value ? value.slice(5, 7) : ''
return (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-month`} className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_month')}
</Label>
<Select
value={selectedMonth}
onValueChange={(month) => {
const selectedCandidate = selectedYear ? (`${selectedYear}-${month}` as fb.Lockdate) : undefined
const nextValue =
selectedCandidate && !unavailableSet.has(selectedCandidate)
? selectedCandidate
: availableLockdateOptions.find((option) => option.value.endsWith(`-${month}`))?.value
onChange(nextValue ?? '')
}}
>
<SelectTrigger id={`${id}-month`} className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
</SelectTrigger>
<SelectContent>
{monthOptions.map((option) => {
const candidate = selectedYear ? (`${selectedYear}-${option.value}` as fb.Lockdate) : undefined
const disabled = candidate
? !availableLockdateOptions.some((lockdate) => lockdate.value === candidate)
: !availableLockdateOptions.some((lockdate) => lockdate.value.endsWith(`-${option.value}`))
return (
<SelectItem key={option.value} value={option.value} disabled={disabled}>
{option.label}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-year`} className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_year')}
</Label>
<Select
value={selectedYear}
onValueChange={(year) => {
const selectedCandidate = selectedMonth ? (`${year}-${selectedMonth}` as fb.Lockdate) : undefined
const nextValue =
selectedCandidate && !unavailableSet.has(selectedCandidate)
? selectedCandidate
: availableLockdateOptions.find((option) => option.value.startsWith(`${year}-`))?.value
onChange(nextValue ?? '')
}}
>
<SelectTrigger id={`${id}-year`} className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
</SelectTrigger>
<SelectContent>
{yearOptions.map((option) => {
const candidate = selectedMonth ? (`${option.value}-${selectedMonth}` as fb.Lockdate) : undefined
const disabled = candidate
? !availableLockdateOptions.some((lockdate) => lockdate.value === candidate)
: !availableLockdateOptions.some((lockdate) => lockdate.value.startsWith(`${option.value}-`))
return (
<SelectItem key={option.value} value={option.value} disabled={disabled}>
{option.label}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
{value && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<p className="text-muted-foreground text-sm">
{t('earn.fidelity_bond.select_date.label_selected_lock_date')}
</p>
<p className="mt-1 text-lg font-semibold">{fb.lockdate.toDateLabel(value)}</p>
</div>
)}
</>
)
}

View file

@ -1,29 +0,0 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
type StepProgressProps = {
currentStep: number
totalSteps: number
}
export function StepProgress({ currentStep, totalSteps }: StepProgressProps) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center gap-1.5">
<div className="flex items-center justify-center gap-2">
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={cn(
'h-1.5 rounded-full transition-all duration-300',
i < currentStep ? 'bg-primary w-8' : i === currentStep ? 'bg-primary w-12' : 'bg-muted w-8',
)}
/>
))}
</div>
<p className="text-muted-foreground text-xs">
{t('global.step_label', { current: currentStep + 1, total: totalSteps })}
</p>
</div>
)
}

View file

@ -1,43 +0,0 @@
import type { ReactNode } from 'react'
import { ChevronLeftIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/spinner'
type WizardStepFooterProps = {
/** omit to hide the back button (first step) */
onBack?: () => void
onCancel: () => void
primaryLabel: ReactNode
onPrimary: () => void
primaryDisabled?: boolean
isLoading?: boolean
}
export function WizardStepFooter({
onBack,
onCancel,
primaryLabel,
onPrimary,
primaryDisabled = false,
isLoading = false,
}: WizardStepFooterProps) {
const { t } = useTranslation()
return (
<>
{onBack && (
<Button variant="ghost" onClick={onBack} disabled={isLoading}>
<ChevronLeftIcon />
{t('global.back')}
</Button>
)}
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
{t('global.cancel')}
</Button>
<Button onClick={onPrimary} disabled={primaryDisabled || isLoading}>
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{primaryLabel}
</Button>
</>
)
}

View file

@ -1,46 +0,0 @@
import { useState } from 'react'
import { directsendMutation, freezeMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useApiClient } from '@/hooks/useApiClient'
import { getErrorReason } from '@/lib/errorReason'
/**
* The freeze/unfreeze/direct-send mutations shared by all fidelity bond flows,
* reporting failures through a common error state. The unfreeze/send error
* messages differ per flow (create/renew/move) and are passed as i18n keys.
*/
export function useFidelityBondMutations({
unfreezeErrorKey,
sendErrorKey,
}: {
unfreezeErrorKey: string
sendErrorKey: string
}) {
const { t } = useTranslation()
const client = useApiClient()
const [error, setError] = useState<string | undefined>()
const setErrorWithReason = (error: unknown, message: string) =>
setError(`${message} ${getErrorReason(error, t('global.errors.reason_unknown'))}`)
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)),
})
return { freezeUtxo, unfreezeUtxo, directSend, error, setError }
}

View file

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

View file

@ -1,172 +0,0 @@
import { useMemo } from 'react'
import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useMutation } from '@tanstack/react-query'
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY } from '@/constants/jam'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { delayedPromise, type WalletFileName } from '@/lib/utils'
import { useFidelityBondMutations } from './useFidelityBondMutations'
/**
* Sweeps a fidelity bond UTXO to a destination address: freezes all other
* UTXOs in the source jar, unfreezes the bond if needed, direct-sends the
* whole jar balance, then restores the frozen UTXOs. Shared by the renew
* and move-to-jar flows.
*/
export function useFidelityBondSweep({
walletFileName,
utxo,
unfreezeErrorKey,
sendErrorKey,
}: {
walletFileName: WalletFileName
utxo: FidelityBondUtxo
unfreezeErrorKey: string
sendErrorKey: string
}) {
const { jars, refetch: walletInfoRefetch } = useJamWalletInfoContext()
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
unfreezeErrorKey,
sendErrorKey,
})
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(() => {
if (!sourceJar) return []
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
}, [sourceJar, utxo.utxo])
/**
* Runs the sweep. `onBroadcast` fires as soon as the transaction is sent,
* before the frozen UTXOs are restored. Returns false when the sweep failed
* (after a best-effort rollback).
*/
const { isPending, mutateAsync: sweep } = useMutation({
mutationFn: async ({
destination,
tryFreezeAfterBroadcast,
onBroadcastSuccess,
}: {
destination: string
tryFreezeAfterBroadcast: boolean
onBroadcastSuccess?: (result: DirectSendResponse) => Promise<void>
}): Promise<DirectSendResponse | undefined> => {
setError(undefined)
const frozen: Utxo[] = []
let bondWasUnfrozen = false
let sweepBroadcasted = 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)
}
if (utxo.frozen) {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
bondWasUnfrozen = true
}
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: utxo.mixdepth,
amount_sats: 0, // 0 := sweep!
destination,
},
})
sweepBroadcasted = true
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO.')
}
}
if (tryFreezeAfterBroadcast) {
try {
await delayedPromise(JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY)
const fbUtxoId = `${result.txinfo.txid}:0` // sent with a sweep, so it is a single output
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': fbUtxoId, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.warn('Error while freezing Fidelity Bond UTXO - continuing.')
}
}
try {
await onBroadcastSuccess?.(result)
} catch (error: unknown) {
console.warn('onBroadcastSuccess failed', error)
}
return result
} catch (_ignoredOnPurpose: unknown) {
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
// only debug output
console.debug('Error while unfreezing previously frozen UTXO in error handling.')
}
}
// re-freeze the bond if it was unfrozen before the error. skip once the
// sweep already broadcast, the bond utxo is spent by then
if (bondWasUnfrozen && !sweepBroadcasted) {
try {
await freezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: true },
throwOnError: true,
})
} catch (_ignoredOnPurpose: unknown) {
console.debug('Error while re-freezing fidelity bond UTXO in error handling.')
}
}
return undefined
} finally {
// refetch only if the sweep broadcast, and don't let a refetch error
// get treated as a sweep failure
if (sweepBroadcasted) {
try {
await walletInfoRefetch()
} catch (error: unknown) {
console.warn('Error while refetching wallet info after sweep.', error)
}
}
}
},
retry: false,
})
return { sweep, isLoading: isPending, error, setError, sourceJar }
}

View file

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

View file

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

View file

@ -15,7 +15,7 @@ export const EarnReportOverlay = ({ open, onOpenChange, ...dialogProps }: EarnRe
return (
<Dialog open={open} onOpenChange={onOpenChange} {...dialogProps}>
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-dvh max-h-dvh! max-w-screen! flex-col overflow-hidden rounded-none border-none">
<DialogContent className="data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom flex h-screen max-w-screen! flex-col rounded-none border-none">
<DialogHeader>
<DialogTitle>
<PageTitle title={t('earn.report.title')} />

View file

@ -57,12 +57,12 @@ describe('yieldgenReportToEarnReportEntries', () => {
it('should handle unexpected/malformed data in a sane way', () => {
const unexpectedHeader = EXPECTED_HEADER_LINE + ',foo,bar'
const emptyLine = ''
const onlyNewline = '\n'
const onlyNewLine = '\n'
const shortLine = '2009/01/03 04:04:04,,,'
const longLine = '2009/01/03 05:05:05,,,,,,,,,,,,,,,,,,,,,,,'
const malformedLine = 'this,is,a,malformed,line,with,some,unexpected,data'
const exampleData = [unexpectedHeader, emptyLine, onlyNewline, shortLine, longLine, malformedLine]
const exampleData = [unexpectedHeader, emptyLine, onlyNewLine, shortLine, longLine, malformedLine]
const entries = yieldgenReportToEarnReportEntries(exampleData)

View file

@ -18,11 +18,11 @@ vi.mock('@tanstack/react-query', () => ({
},
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
yieldgenreportQueryKey: () => ['yieldgenreport'],
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
yieldgenreport: () => yieldgenreportMock(),
}))
@ -45,12 +45,11 @@ describe('useQueryYieldgenReport', () => {
expect(parserMock).toHaveBeenCalledWith(['a', 'b'])
})
it('empty array if yigen_data is undefined', async () => {
yieldgenreportMock.mockResolvedValue({ data: { yigen_data: undefined } })
it('parses a plain string array response', async () => {
yieldgenreportMock.mockResolvedValue({ data: ['x'] })
renderHook(() => useQueryYieldgenReport())
await expect(runQueryFn()).resolves.toEqual([])
expect(parserMock).toHaveBeenCalledWith([])
await expect(runQueryFn()).resolves.toEqual(['x'])
})
it('returns an empty array on a 404 error', async () => {

View file

@ -1,5 +1,6 @@
import { yieldgenreportQueryKey } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { yieldgenreport } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { yieldgenreportQueryKey } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import { yieldgenreport } from '@joinmarket-webui/joinmarket-api-ts/jm'
import type { ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useQuery } from '@tanstack/react-query'
import { yieldgenReportToEarnReportEntries } from '@/components/earn/report/hooks/earnReportParser'
import { useApiClient } from '@/hooks/useApiClient'
@ -10,14 +11,23 @@ export type { EarnReportEntry } from '@/components/earn/report/hooks/earnReportP
export function useQueryYieldgenReport({ enabled = true }: { enabled?: boolean } = {}) {
const client = useApiClient()
return useQuery<ReturnType<typeof yieldgenReportToEarnReportEntries>>({
return useQuery<ReturnType<typeof yieldgenReportToEarnReportEntries>, ErrorMessage>({
queryKey: yieldgenreportQueryKey(),
queryFn: async ({ signal }) => {
try {
const response = await yieldgenreport({ client, signal })
const lines = response.data?.yigen_data ?? []
let lines: string[]
// Wrong type generated by API! Try to parse actual format `{ yigen_data: [] }`!
// see: https://github.com/JoinMarket-Org/joinmarket-clientserver/pull/1836 (last checked: 2026-03-09)
const data = response.data as unknown
if (data && typeof data === 'object' && 'yigen_data' in data && Array.isArray(data.yigen_data)) {
lines = (data.yigen_data ?? []) as string[]
} else {
lines = response.data ?? []
}
// API returns string[] (CSV lines with header)
return yieldgenReportToEarnReportEntries(lines)
} catch (error: unknown) {
// 404 is returned until the maker is started at least once

View file

@ -18,6 +18,20 @@ vi.mock('react-i18next', () => ({
Trans: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
<div>
<span>{title}</span>
<span>{subtitle}</span>
</div>
),
}))
describe('ErrorPage', () => {
it('renders error details when error is an Error instance', () => {
const error = new Error('boom')
@ -50,15 +64,6 @@ describe('ErrorPage', () => {
expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument()
})
it('falls back to reason_unknown for a error without message', () => {
routeError.mockReturnValue({ message: '' })
render(<ErrorPage />)
expect(screen.getByText('error_page.unknown_error.title')).toBeInTheDocument()
expect(screen.getByText('global.errors.reason_unknown')).toBeInTheDocument()
})
it('falls back to reason_unknown when object has no message', () => {
routeError.mockReturnValue({ foo: 'bar' })

View file

@ -3,7 +3,6 @@ import { Trans, useTranslation } from 'react-i18next'
import { useRouteError } from 'react-router-dom'
import { Alert, AlertDescription } from '@/components/ui/alert'
import PageTitle from '@/components/ui/jam/PageTitle'
import { JAM_REPO_URL } from '@/constants/jam'
interface ErrorViewProps {
title: string
@ -20,7 +19,7 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
<Trans i18nKey="error_page.report_bug">
Please{' '}
<a
href={`${JAM_REPO_URL}/issues/new?labels=bug&template=bug_report.md`}
href="https://github.com/joinmarket-webui/jam/issues/new?labels=bug&template=bug_report.md"
target="_blank"
rel="noopener noreferrer"
className="font-semibold underline hover:no-underline"
@ -33,15 +32,15 @@ function ErrorView({ title, subtitle, reason, stacktrace }: ErrorViewProps) {
<div className="my-4">
<h6 className="font-semibold">{t('error_page.heading_reason')}</h6>
<Alert variant="destructive" className="overflow-x-auto">
<AlertDescription className="min-w-0 break-words">{reason}</AlertDescription>
<Alert variant="destructive" className="overflow-scroll">
<AlertDescription className="flex items-center justify-between">{reason}</AlertDescription>
</Alert>
</div>
{stacktrace && (
<div className="my-4">
<h6 className="font-semibold">{t('error_page.heading_stacktrace')}</h6>
<pre className="max-w-full overflow-x-auto rounded-lg border p-2">
<pre className="overflow-scroll rounded-lg border p-2">
<code>{stacktrace}</code>
</pre>
</div>

View file

@ -1,11 +1,7 @@
import type { ReactNode } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
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'
vi.mock('react-i18next', () => ({
@ -14,6 +10,8 @@ vi.mock('react-i18next', () => ({
}),
}))
vi.mock('../dev/DevBadge', () => ({ DevBadge: () => <span data-testid="dev-badge" /> }))
vi.mock('../ui/accordion', () => ({
Accordion: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AccordionItem: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
@ -21,79 +19,95 @@ vi.mock('../ui/accordion', () => ({
AccordionContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
const VALID_MNEMONIC = DUMMY_SEED_PHRASE.join(' ')
vi.mock('../ui/alert', () => ({
Alert: ({ children, variant }: { children?: ReactNode; variant?: string }) => (
<div data-variant={variant}>{children}</div>
),
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertTitle: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/field', () => ({
Field: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
FieldLabel: ({ children }: { children?: ReactNode }) => <label>{children}</label>,
}))
vi.mock('../ui/input-group', () => ({
InputGroup: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupAddon: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
InputGroupInput: (props: Record<string, unknown>) => <input {...props} />,
}))
vi.mock('../ui/textarea', () => ({
Textarea: (props: Record<string, unknown>) => <textarea {...props} />,
}))
vi.mock('../ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
type,
}: {
children?: ReactNode
onClick?: () => void
disabled?: boolean
type?: 'button' | 'submit'
}) => (
<button onClick={onClick} disabled={disabled} type={type}>
{children}
</button>
),
}))
vi.mock('@/components/ui/spinner', () => ({ Spinner: () => <div data-testid="spinner" /> }))
const VALID_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const typeMnemonic = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_mnemonic_phrase'), {
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_menmonic_phrase'), {
target: { value },
})
}
const typeBlockheight = (value: BlockHeight) => {
fireEvent.change(screen.getByPlaceholderText('import_wallet.import_details.placeholder_blockheight'), {
target: { value },
})
}
const mockedSessionInfo: SessionResponse = { session: false, maker_running: false, coinjoin_in_process: false }
describe('ImportDetailsForm', () => {
it('renders the mnemonic, blockheight and gaplimit fields', async () => {
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} />)
expect(screen.getByText('import_wallet.import_details.label_mnemonic_phrase')).toBeInTheDocument()
render(<ImportDetailsForm onSubmit={vi.fn()} />)
expect(screen.getByText('import_wallet.import_details.label_menmonic_phrase')).toBeInTheDocument()
expect(document.querySelector('#blockheight')).toBeInTheDocument()
expect(document.querySelector('#gaplimit')).toBeInTheDocument()
await flushActUpdates()
})
it('shows a success alert when the mnemonic is a valid BIP-39 phrase', async () => {
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} />)
render(<ImportDetailsForm onSubmit={vi.fn()} />)
typeMnemonic(VALID_MNEMONIC)
expect(screen.getByText('import_wallet.import_details.text_mnemonic_valid')).toBeInTheDocument()
expect(screen.getByText('Mnemonic phrase is valid')).toBeInTheDocument()
await flushActUpdates()
})
it('warns and does not submit when the mnemonic is not recognized', async () => {
const onSubmit = vi.fn()
render(<ImportDetailsForm onSubmit={onSubmit} sessionInfo={mockedSessionInfo} />)
render(<ImportDetailsForm onSubmit={onSubmit} />)
typeMnemonic('not a real seed phrase at all')
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(screen.getByText('import_wallet.import_details.alert_mnemonic_not_recognized_title')).toBeInTheDocument(),
)
await waitFor(() => expect(screen.getByText('Mnemonic phrase is not recognized')).toBeInTheDocument())
expect(onSubmit).not.toHaveBeenCalled()
})
it('warns and does not submit when the blockheight is larger than current blockheight', async () => {
await withRuntimeLocale('en-US', async () => {
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
it('submits when all values are valid', async () => {
const onSubmit = vi.fn()
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={mockedSessionInfo}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700_000, gaplimit: 50 }}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 50 }}
/>,
)
fireEvent.submit(document.querySelector('form')!)
@ -104,8 +118,7 @@ describe('ImportDetailsForm', () => {
render(
<ImportDetailsForm
onSubmit={vi.fn()}
sessionInfo={mockedSessionInfo}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700_000, gaplimit: 9_999 }}
initialValues={{ mnemonicPhrase: VALID_MNEMONIC, blockheight: 700000, gaplimit: 9999 }}
/>,
)
expect(screen.getByText('import_wallet.import_details.alert_high_gaplimit_value')).toBeInTheDocument()
@ -113,7 +126,7 @@ describe('ImportDetailsForm', () => {
})
it('renders a disabled submit button when disabled', async () => {
render(<ImportDetailsForm onSubmit={vi.fn()} sessionInfo={mockedSessionInfo} disabled />)
render(<ImportDetailsForm onSubmit={vi.fn()} disabled />)
const submit = document.querySelector('button[type="submit"]')
expect(submit).toBeDisabled()
await flushActUpdates()

View file

@ -1,6 +1,5 @@
import { useMemo } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { validateMnemonic } from '@scure/bip39'
import { wordlist } from '@scure/bip39/wordlists/english.js'
import type { TFunction } from 'i18next'
@ -16,14 +15,12 @@ import { useForm, useWatch, type Mode, type SubmitHandler } from 'react-hook-for
import { useTranslation } from 'react-i18next'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Spinner } from '@/components/ui/spinner'
import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { GAPLIMIT_WARN_THRESHOLD } from '@/constants/jam'
import { JM_GAPLIMIT_DEFAULT } from '@/constants/jm'
import { blockHeightField, INPUT_BLOCK_HEIGHT_MIN } from '@/lib/formValidation'
import { cn, DUMMY_SEED_PHRASE, isValidInteger, SEGWIT_ACTIVATION_BLOCK } from '@/lib/utils'
import type { BlockHeight } from '@/types/global'
import { DevBadge } from '../dev/DevBadge'
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion'
import { Alert, AlertDescription, AlertTitle } from '../ui/alert'
@ -40,6 +37,17 @@ const GAPLIMIT_SUGGESTIONS = {
heavy: JM_GAPLIMIT_DEFAULT * 4,
}
const MIN_BLOCKHEIGHT_VALUE = 1
/**
* Maximum blockheight value.
* Value choosen based on estimation of blockheight in tge year 2140 (plus some buffer):
* 365 × 144 × (2140 - 2009) = 6_885_360 = ~7_000_000
* This is necessary because javascript does not handle large values too well,
* and the `/rescanblockchain` errors. Not to mention that a value beyond the current
* height does not make any sense in the first place.
*/
const MAX_BLOCKHEIGHT_VALUE = 10_000_000
const MIN_GAPLIMIT_VALUE = 1
/**
* Maximum gaplimit value for importing an existing wallet.
@ -62,7 +70,7 @@ interface ImportDetailsFormValues {
const defaultImportDetailsFormValues: ImportDetailsFormValues = isDevMode()
? {
mnemonicPhrase: '',
blockheight: INPUT_BLOCK_HEIGHT_MIN,
blockheight: MIN_BLOCKHEIGHT_VALUE,
gaplimit: GAPLIMIT_SUGGESTIONS.heavy,
}
: {
@ -85,7 +93,10 @@ const isBip39Mnemonic = (value: string) => {
return validateMnemonic(normalized, wordlist)
}
const importDetailsFormSchema = (currentBlockHeight: BlockHeight | undefined, t: TFunction) => {
const importDetailsFormSchema = (t: TFunction) => {
const invalidBlockheightMessage = t('import_wallet.import_details.feedback_invalid_blockheight', {
min: MIN_BLOCKHEIGHT_VALUE.toLocaleString(),
})
const invalidGaplimitMessage = t('import_wallet.import_details.feedback_invalid_gaplimit', {
min: MIN_GAPLIMIT_VALUE.toLocaleString(),
max: MAX_GAPLIMIT_VALUE.toLocaleString(),
@ -95,24 +106,21 @@ const importDetailsFormSchema = (currentBlockHeight: BlockHeight | undefined, t:
mnemonicPhrase: yup
.string()
.transform((currentValue: string | undefined) => normalizeSeedPhrase(currentValue))
.required(t('import_wallet.import_details.feedback_invalid_mnemonic_phrase'))
.required(t('import_wallet.import_details.feedback_invalid_menmonic_phrase'))
.test(
'valid-bip39-mnemonic-phrase-test',
t('import_wallet.import_details.feedback_invalid_mnemonic_phrase'),
t('import_wallet.import_details.feedback_invalid_menmonic_phrase'),
(value) => {
return isBip39Mnemonic(value)
},
),
blockheight: blockHeightField({
currentBlockHeight: currentBlockHeight,
messages: {
invalid: ({ min, max }) =>
t('import_wallet.import_details.feedback_invalid_blockheight', {
min: min.toLocaleString(),
max: max.toLocaleString(),
}),
},
}),
blockheight: yup
.number()
.transform((value) => (isValidInteger(value) ? value : null))
.integer(invalidBlockheightMessage)
.min(MIN_BLOCKHEIGHT_VALUE, invalidBlockheightMessage)
.max(MAX_BLOCKHEIGHT_VALUE, invalidBlockheightMessage)
.required(invalidBlockheightMessage),
gaplimit: yup
.number()
.transform((value) => (isValidInteger(value) ? value : null))
@ -126,7 +134,6 @@ const importDetailsFormSchema = (currentBlockHeight: BlockHeight | undefined, t:
type ImportDetailsFormProps = {
className?: string
sessionInfo: SessionResponse | undefined
onSubmit: SubmitHandler<ImportDetailsFormValues>
initialValues?: ImportDetailsFormValues
disabled?: boolean
@ -135,7 +142,6 @@ type ImportDetailsFormProps = {
export const ImportDetailsForm = ({
className,
sessionInfo,
onSubmit,
initialValues,
disabled,
@ -143,10 +149,7 @@ export const ImportDetailsForm = ({
}: ImportDetailsFormProps) => {
const { t } = useTranslation()
const schema = useMemo(
() => importDetailsFormSchema(sessionInfo?.block_height ?? undefined, t),
[sessionInfo?.block_height, t],
)
const schema = useMemo(() => importDetailsFormSchema(t), [t])
const {
register,
@ -180,40 +183,50 @@ export const ImportDetailsForm = ({
<div className="space-y-2">
<Field data-invalid={errors.mnemonicPhrase !== undefined}>
<FieldLabel htmlFor="import-wallet-seed">
{t('import_wallet.import_details.label_mnemonic_phrase')}
{t('import_wallet.import_details.label_menmonic_phrase')}
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="text-muted-foreground ml-1 inline size-3.5 align-text-bottom" />
</TooltipTrigger>
<TooltipContent>
<p>{t('import_wallet.import_details.hint_seed_word_count', { wordCounts: SEED_WORD_COUNT_HINT })}</p>
{/* TODO: i18n */}
<p>Expects {SEED_WORD_COUNT_HINT} words.</p>
</TooltipContent>
</Tooltip>
</FieldLabel>
<Textarea
id="import-wallet-seed"
rows={4}
placeholder={t('import_wallet.import_details.placeholder_mnemonic_phrase')}
placeholder={t('import_wallet.import_details.placeholder_menmonic_phrase')}
{...register('mnemonicPhrase', {
required: true,
})}
disabled={disabled}
autoComplete="off"
/>
{errors.mnemonicPhrase?.message && <FieldError>{errors.mnemonicPhrase.message}</FieldError>}
</Field>
{errors.mnemonicPhrase?.message && (
<div className="text-destructive text-xs">{errors.mnemonicPhrase.message}</div>
)}
{isSeedPhraseBip39Valid && (
<Alert variant="success" className="py-2">
<CheckCircle2Icon />
<AlertDescription>{t('import_wallet.import_details.text_mnemonic_valid')}</AlertDescription>
<AlertDescription>
{/* TODO: i18n */}
Mnemonic phrase is valid
</AlertDescription>
</Alert>
)}
{isSubmitted && !isSubmitSuccessful && !isValid && !isSeedPhraseBip39Valid && (
<Alert variant="warning" className="py-2">
<AlertTriangleIcon />
<AlertTitle>{t('import_wallet.import_details.alert_mnemonic_not_recognized_title')}</AlertTitle>
<AlertTitle>
{/* TODO: i18n */}
Mnemonic phrase is not recognized
</AlertTitle>
<AlertDescription className="text-sm">
{t('import_wallet.import_details.alert_mnemonic_not_recognized_description')}
{/* TODO: i18n */}
Only BIP-39 compliant mnemonic phrases can be imported. Please review your inputs carefully.
</AlertDescription>
</Alert>
)}
@ -240,8 +253,7 @@ export const ImportDetailsForm = ({
<AccordionTrigger
className={cn({
'text-destructive': hasImportDetailsSectionErrors,
'text-brand-warning light:text-brand-warning-foreground':
!hasImportDetailsSectionErrors && showGaplimitWarning,
'light:text-yellow-800 text-yellow-200/90': !hasImportDetailsSectionErrors && showGaplimitWarning,
})}
>
<div className="flex items-center gap-2">
@ -251,28 +263,33 @@ export const ImportDetailsForm = ({
</div>
</AccordionTrigger>
<AccordionContent className={cn('space-y-2', 'mx-1' /* add x-spacing for input component focus state*/)}>
<Field data-invalid={errors.blockheight !== undefined}>
<FieldLabel htmlFor="blockheight">{t('import_wallet.import_details.label_blockheight')}</FieldLabel>
<FieldDescription className="text-xs">
{t('import_wallet.import_details.description_blockheight')}
</FieldDescription>
<InputGroup>
<InputGroupInput
id="blockheight"
placeholder={t('import_wallet.import_details.placeholder_blockheight')}
{...register('blockheight', {
required: true,
disabled,
})}
type="number"
step={1}
/>
<InputGroupAddon align="inline-start">
<BlocksIcon />
</InputGroupAddon>
</InputGroup>
{errors.blockheight?.message && <FieldError>{errors.blockheight.message}</FieldError>}
</Field>
<div className="space-y-2">
<Field data-invalid={errors.blockheight !== undefined}>
<FieldLabel htmlFor="blockheight">{t('import_wallet.import_details.label_blockheight')}</FieldLabel>
<FieldDescription className="text-xs">
{t('import_wallet.import_details.description_blockheight')}
</FieldDescription>
<InputGroup>
<InputGroupInput
id="blockheight"
{...register('blockheight', {
required: true,
disabled,
})}
type="number"
min={MIN_BLOCKHEIGHT_VALUE}
max={MAX_BLOCKHEIGHT_VALUE}
step={1}
/>
<InputGroupAddon align="inline-start">
<BlocksIcon />
</InputGroupAddon>
</InputGroup>
</Field>
{errors.blockheight?.message && (
<div className="text-destructive text-xs">{errors.blockheight.message}</div>
)}
</div>
<div className="space-y-2">
<Field data-invalid={errors.gaplimit !== undefined}>
<FieldLabel htmlFor="gaplimit">{t('import_wallet.import_details.label_gaplimit')}</FieldLabel>
@ -282,7 +299,6 @@ export const ImportDetailsForm = ({
<InputGroup>
<InputGroupInput
id="gaplimit"
placeholder={t('import_wallet.import_details.placeholder_gaplimit')}
{...register('gaplimit', {
required: true,
disabled,
@ -296,8 +312,8 @@ export const ImportDetailsForm = ({
<UnfoldHorizontalIcon />
</InputGroupAddon>
</InputGroup>
{errors.gaplimit?.message && <FieldError>{errors.gaplimit.message}</FieldError>}
</Field>
{errors.gaplimit?.message && <div className="text-destructive text-xs">{errors.gaplimit.message}</div>}
{showGaplimitWarning && (
<Alert variant="warning">

View file

@ -105,7 +105,7 @@ export const ImportStepConfirm = ({
<Accordion type="single" collapsible>
<AccordionItem value="mnemonic">
<AccordionTrigger>{t('import_wallet.import_details.label_mnemonic_phrase')}</AccordionTrigger>
<AccordionTrigger>{t('import_wallet.import_details.label_menmonic_phrase')}</AccordionTrigger>
<AccordionContent className="bg-muted mb-2 rounded-lg p-2">
<SeedPhraseGrid value={importDetails.mnemonicPhrase.split(/\s+/)} masked={!revealSensitiveInfo} />
</AccordionContent>
@ -113,7 +113,7 @@ export const ImportStepConfirm = ({
<AccordionItem value="options">
<AccordionTrigger
className={cn({
'text-brand-warning light:text-brand-warning-foreground': showGaplimitWarning,
'light:text-yellow-800 text-yellow-200/90': showGaplimitWarning,
})}
>
<div className="flex items-center gap-2">
@ -127,14 +127,14 @@ export const ImportStepConfirm = ({
<div className="text-muted-foreground text-xs">
{t('import_wallet.import_details.description_blockheight')}
</div>
<div className="text-xl">{importDetails.blockheight.toLocaleString()}</div>
<div className="text-xl">{importDetails.blockheight}</div>
</div>
<div>
<div>{t('import_wallet.import_details.label_gaplimit')}</div>
<div className="text-muted-foreground text-xs">
{t('import_wallet.import_details.description_gaplimit')}
</div>
<div className="text-xl">{importDetails.gaplimit.toLocaleString()}</div>
<div className="text-xl">{importDetails.gaplimit}</div>
</div>
{showGaplimitWarning && (
<Alert variant="warning">
@ -150,22 +150,15 @@ export const ImportStepConfirm = ({
</div>
<div className="space-y-2">
<Field
data-invalid={errors.revealSensitiveInfo !== undefined}
orientation="horizontal"
className="items-start"
>
<Field data-invalid={errors.revealSensitiveInfo !== undefined} orientation="horizontal">
<Switch
className="mt-0.5 shrink-0"
id="switch-reveal-seed"
checked={revealSensitiveInfo}
onCheckedChange={(checked) =>
setValue('revealSensitiveInfo', checked, { shouldValidate: true, shouldTouch: true })
}
/>
<FieldLabel htmlFor="switch-reveal-seed" className="min-w-0 flex-1">
{t('create_wallet.confirmation_toggle_reveal_info')}
</FieldLabel>
<FieldLabel htmlFor="switch-reveal-seed">{t('create_wallet.confirmation_toggle_reveal_info')}</FieldLabel>
</Field>
{errors.revealSensitiveInfo?.message && (
<div className="text-destructive text-xs">{errors.revealSensitiveInfo.message}</div>

View file

@ -1,4 +1,5 @@
import type { ComponentProps } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { ChevronLeftIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@ -8,6 +9,7 @@ import { ImportDetailsForm } from './ImportDetailsForm'
import { RescanActiveAlert } from './RescanActiveAlert'
type ImportStepImportDetailsProps = ComponentProps<typeof ImportDetailsForm> & {
sessionInfo: SessionResponse | undefined
onBack: () => void
}
@ -19,13 +21,10 @@ export const ImportStepImportDetails = ({ sessionInfo, onBack, ...importFormProp
return (
<div className="space-y-2">
{isSessionActive && (
<OtherWalletActiveAlert
linkTarget={'login'}
walletFileName={sessionInfo.wallet_name as WalletFileName | undefined}
/>
<OtherWalletActiveAlert linkTarget={'login'} walletFileName={sessionInfo.wallet_name as WalletFileName} />
)}
{isRescanActive && <RescanActiveAlert linkTarget={'login'} />}
{showForm && <ImportDetailsForm sessionInfo={sessionInfo} {...importFormProps} />}
{showForm && <ImportDetailsForm {...importFormProps} />}
<Button variant="ghost" onClick={onBack}>
<ChevronLeftIcon />
{t('global.back')}

View file

@ -1,6 +1,5 @@
import type { ComponentProps } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import { useTranslation } from 'react-i18next'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { Link } from 'react-router-dom'
import { CreateWalletForm } from '@/components/create/CreateWalletForm'
import { Button } from '@/components/ui/button'
@ -14,26 +13,23 @@ type ImportStepWalletDetailsProps = ComponentProps<typeof CreateWalletForm> & {
}
export const ImportStepWalletDetails = ({ sessionInfo, ...createFormProps }: ImportStepWalletDetailsProps) => {
const { t } = useTranslation()
const isSessionActive = sessionInfo?.session === true
const isRescanActive = sessionInfo?.rescanning === true
const showForm = !isSessionActive && !isRescanActive
return (
<div className="space-y-2">
{isSessionActive && (
<OtherWalletActiveAlert
linkTarget={'login'}
walletFileName={sessionInfo.wallet_name as WalletFileName | undefined}
/>
<OtherWalletActiveAlert linkTarget={'login'} walletFileName={sessionInfo.wallet_name as WalletFileName} />
)}
{isRescanActive && <RescanActiveAlert linkTarget={'login'} />}
{showForm && <CreateWalletForm {...createFormProps} />}
<div className="text-center">
<p className="text-muted-foreground text-sm">
{t('create_wallet.text_login_hint')}{' '}
{/* TODO: i18n */}
Already have a wallet?{' '}
<Button variant="link" asChild>
<Link to={routes.login} className="font-semibold">
{t('create_wallet.button_login_link')}
Sign in here
</Link>
</Button>
</p>

View file

@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => ({
type MutationOptions = { mutationFn: (input: unknown) => Promise<unknown> }
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
configgetMutation: vi.fn(() => ({ mutationFn: mocks.configGet })),
configsettingMutation: vi.fn(() => ({ mutationFn: mocks.configSet })),
listwalletsOptions: vi.fn(() => ({ queryKey: ['wallets'], queryFn: vi.fn() })),
@ -32,7 +32,7 @@ vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
unlockwalletMutation: vi.fn(() => ({ mutationFn: mocks.unlockWallet })),
}))
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/jm', () => ({
vi.mock('@joinmarket-webui/joinmarket-api-ts/jm', () => ({
lockwallet: mocks.lockWallet,
rescanblockchain: mocks.rescanBlockchain,
session: mocks.session,

View file

@ -6,8 +6,8 @@ import {
listwalletsOptions,
recoverwalletMutation,
unlockwalletMutation,
} from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query'
import { lockwallet, rescanblockchain, session } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import { lockwallet, rescanblockchain, session } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { CircleCheckBigIcon, KeyRoundIcon, WalletIcon, type LucideIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@ -50,8 +50,8 @@ const ImportWalletCard = ({
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
<Icon className="text-primary" />
</div>
<CardTitle className="text-center text-xl font-bold break-words">{title}</CardTitle>
{description !== undefined && <CardDescription className="text-center">{description}</CardDescription>}
<CardTitle className="text-xl font-bold">{title}</CardTitle>
{description !== undefined && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className="space-y-6">{children}</CardContent>
@ -253,7 +253,7 @@ const ImportWalletPage = () => {
})
toast.success(t('rescan_chain.success_rescan_started'))
} catch (error: unknown) {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
const reason = getErrorReason(error, 'Unknown error.')
console.warn('Non-critical error while fetching session after wallet import. Continuing with import...', reason)
}
@ -276,7 +276,7 @@ const ImportWalletPage = () => {
token: authState.auth.token,
})
} catch (error: unknown) {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
const reason = getErrorReason(error, 'Unknown error.')
console.warn('Locking wallet attempt failed after import error.', reason)
}
}

View file

@ -3,16 +3,12 @@ import { BlocksIcon, BookOpenIcon, FileQuestionMarkIcon, ScrollTextIcon } from '
import { useTranslation, Trans } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { JmWebsocketInfo } from '@/components/ui/jam/JmWebsocketInfo'
import { JAM_REPO_URL } from '@/constants/jam'
import type { JmWebsocket } from '@/hooks/useJmWebsocket'
import { BetaWarningDialog } from './footer/BetaWarningDialog'
type JmWebsocketInfo = Pick<JmWebsocket, 'isOpen' | 'isAuthenticated'>
type AppFooterProps = Pick<
ComponentProps<typeof BetaWarningDialog>,
'jamVersion' | 'backendVersion' | 'backendName'
> & {
type AppFooterProps = Pick<ComponentProps<typeof BetaWarningDialog>, 'jamVersion' | 'joinmarketVersion'> & {
blockHeight?: number
websocketInfo?: JmWebsocketInfo
onClickCheatsheet: () => void
@ -24,8 +20,7 @@ export function AppFooter({
blockHeight,
websocketInfo,
jamVersion,
backendVersion,
backendName,
joinmarketVersion,
onClickCheatsheet,
onClickOrderbook,
onClickLogs,
@ -39,46 +34,27 @@ export function AppFooter({
<BetaWarningDialog
open={isShowBetaWarning}
onOpenChange={setShowBetaWarning}
backendVersion={backendVersion}
backendName={backendName}
joinmarketVersion={joinmarketVersion}
jamVersion={jamVersion}
/>
<footer>
<div className="flex flex-col items-center justify-between gap-8 px-4 py-12 sm:flex-row sm:gap-2 sm:p-4">
<div
className="flex w-full flex-col justify-start gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-center"
className="flex flex-col justify-start gap-2 sm:flex-row sm:items-center sm:justify-center"
data-tour-id="footer-tools"
>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={onClickCheatsheet}
title={t('footer.cheatsheet')}
>
<Button variant="outline" size="sm" onClick={onClickCheatsheet} title={t('footer.cheatsheet')}>
<FileQuestionMarkIcon />
<span>{t('footer.cheatsheet')}</span>
<span className="">{t('footer.cheatsheet')}</span>
</Button>
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={onClickOrderbook}
title={t('footer.orderbook')}
>
<Button variant="outline" size="sm" onClick={onClickOrderbook} title={t('footer.orderbook')}>
<BookOpenIcon />
<span>{t('footer.orderbook')}</span>
<span className="">{t('footer.orderbook')}</span>
</Button>
{onClickLogs && (
<Button
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={onClickLogs}
title={t('footer.logs')}
>
<Button variant="outline" size="sm" onClick={onClickLogs} title={t('footer.logs')}>
<ScrollTextIcon />
<span>{t('footer.logs')}</span>
<span className="">{t('footer.logs')}</span>
</Button>
)}
</div>
@ -99,7 +75,7 @@ export function AppFooter({
</span>
)}
<a
href={`${JAM_REPO_URL}/tags`}
href="https://github.com/joinmarket-webui/jam/tags"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground underline"
@ -108,7 +84,7 @@ export function AppFooter({
</a>
</div>
</div>
<div className="order-last min-w-0 flex-1 text-center text-xs break-words sm:order-first sm:text-left">
<div className="order-last flex-1 text-xs sm:order-first">
<Trans i18nKey="footer.warning">
This is pre-alpha software.
<Button

View file

@ -1,85 +0,0 @@
import type { ComponentProps, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { AppNavbar } from './AppNavbar'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => key + (options ? ' ' + JSON.stringify(options) : ''),
}),
}))
vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={String(to)}>{children}</a>,
}))
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({ mutateAsync: vi.fn().mockResolvedValue(undefined), isPending: false }),
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString?: string }) => <span data-testid="balance">{valueString}</span>,
}))
const LOCK_WALLET_LABEL = 'settings.button_lock_wallet'
const LOGOUT_LABEL = 'navbar.button_logout'
const defaults: ComponentProps<typeof AppNavbar> = {
theme: 'dark',
toggleTheme: vi.fn(),
onLogout: vi.fn().mockResolvedValue(undefined),
onLockWallet: vi.fn().mockResolvedValue(undefined),
walletName: 'Satoshi',
totalBalance: 21_000_000,
}
const queryLockWalletButton = () => screen.queryByRole('button', { name: LOCK_WALLET_LABEL })
const queryLogoutButton = () => screen.queryByRole('button', { name: LOGOUT_LABEL })
describe('AppNavbar lock/logout toggle', () => {
it('shows "lock wallet" and hides "logout" when idle', () => {
render(<AppNavbar {...defaults} />)
expect(queryLockWalletButton()).toBeInTheDocument()
expect(queryLogoutButton()).not.toBeInTheDocument()
})
it('shows "logout" and hides "lock wallet" while the maker is running', () => {
render(
<AppNavbar
{...defaults}
sessionInfo={{ maker_running: true, coinjoin_in_process: false, schedule: undefined }}
/>,
)
expect(queryLogoutButton()).toBeInTheDocument()
expect(queryLockWalletButton()).not.toBeInTheDocument()
})
it('shows "logout" and hides "lock wallet" while a taker coinjoin is running', () => {
render(
<AppNavbar
{...defaults}
sessionInfo={{ maker_running: false, coinjoin_in_process: true, schedule: undefined }}
/>,
)
expect(queryLogoutButton()).toBeInTheDocument()
expect(queryLockWalletButton()).not.toBeInTheDocument()
})
it('shows "logout" and hides "lock wallet" while the scheduler is running', () => {
render(<AppNavbar {...defaults} sessionInfo={{ maker_running: false, coinjoin_in_process: true, schedule: [] }} />)
expect(queryLogoutButton()).toBeInTheDocument()
expect(queryLockWalletButton()).not.toBeInTheDocument()
})
it('shows "logout" and hides "lock wallet" while a rescan is in progress', () => {
render(<AppNavbar {...defaults} rescanInfo={{ rescanning: true, progress: undefined, updatedAt: Date.now() }} />)
expect(queryLogoutButton()).toBeInTheDocument()
expect(queryLockWalletButton()).not.toBeInTheDocument()
})
})

View file

@ -1,5 +1,4 @@
import type { ComponentProps } from 'react'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm'
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation } from '@tanstack/react-query'
import type { TFunction } from 'i18next'
import { LockKeyholeIcon, LogOutIcon, PackageSearchIcon, SettingsIcon, ShuffleIcon, WalletIcon } from 'lucide-react'
@ -7,15 +6,16 @@ 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'
import { isDevMode } from '@/constants/debugFeatures'
import { routes } from '@/constants/routes'
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'
@ -25,7 +25,6 @@ type WalletPreviewProps = {
walletName: string | null
totalBalance: AmountSats
rescanInfo?: RescanInfo
development?: boolean
}
const WalletPreview = ({
@ -34,14 +33,13 @@ const WalletPreview = ({
isLoading = false,
isReloading = false,
rescanInfo,
development = false,
}: WalletPreviewProps) => {
const { t } = useTranslation()
const walletNameTitle = shortenStringMiddle(walletName ?? '...', 12)
return (
<div className="flex min-w-0 flex-1 items-center">
<Link to={routes.home} className="flex min-w-0 items-center gap-2" data-tour-id="wallet-preview">
<div className="flex flex-1 items-center">
<Link to={routes.home} className="flex items-center gap-2" data-tour-id="wallet-preview">
<div className="flex h-8 w-8 items-center">
{isLoading || isReloading ? (
<Spinner className="text-muted-foreground size-6 motion-reduce:hidden" strokeWidth={3} />
@ -49,7 +47,7 @@ const WalletPreview = ({
<WalletIcon strokeWidth={1} />
)}
</div>
<div className="flex min-w-0 flex-col gap-0.25 leading-none">
<div className="flex flex-col gap-0.25 leading-none">
<div className="flex items-center gap-2">
<div
className="font-semibold tracking-tight"
@ -57,21 +55,21 @@ const WalletPreview = ({
>
{walletNameTitle}
</div>
{development && <DevBadge />}
{isDevMode() && <DevBadge />}
</div>
<div className="flex min-h-5 min-w-0 items-center sm:min-w-[150px]">
<div className="flex min-h-5 min-w-[150px] items-center">
{rescanInfo?.rescanning === true ? (
<div className="cursor-wait motion-safe:animate-pulse">
{rescanInfo.progressInPercentage === undefined
? t('navbar.text_rescan_in_progress')
: t('navbar.text_rescan_in_progress_with_progress', {
progress: rescanInfo.progressInPercentage,
})}
{rescanInfo.progress !== undefined
? t('navbar.text_rescan_in_progress_with_progress', {
progress: Math.floor(rescanInfo.progress * 100),
})
: t('navbar.text_rescan_in_progress')}
</div>
) : (
<>
{isLoading ? (
<Skeleton className="bg-brand-nav-foreground/25 h-4 w-full" />
<Skeleton className="h-4 w-full bg-neutral-200 dark:bg-neutral-600" />
) : (
<Balance valueString={String(totalBalance)} />
)}
@ -96,7 +94,6 @@ type AppNavbarProps = WalletPreviewProps & {
sessionInfo?: SessionInfo
sidebarInfo?: SidebarInfo
rescanInfo?: RescanInfo
tooltipSide?: ComponentProps<typeof TooltipContent>['side']
}
export function AppNavbar({
@ -112,8 +109,6 @@ export function AppNavbar({
sessionInfo,
sidebarInfo,
rescanInfo,
tooltipSide,
development,
}: AppNavbarProps) {
const { t } = useTranslation()
const navigate = useNavigate()
@ -125,10 +120,6 @@ export function AppNavbar({
const singleCoinJoinRunning = sessionInfo?.coinjoin_in_process === true && !sessionInfo?.schedule
const schedulerRunning = sessionInfo?.coinjoin_in_process === true && !!sessionInfo?.schedule
// While a maker/taker/rescan is in progress the wallet cannot be locked, so
// only offer "logout"; otherwise offer "lock wallet".
const serviceRunning = makerRunning || sessionInfo?.coinjoin_in_process === true || rescanInfo?.rescanning === true
const joiningRoute = (() => {
if (schedulerRunning) return routes.sweep
if (singleCoinJoinRunning) return routes.send
@ -154,18 +145,17 @@ export function AppNavbar({
})
return (
<header className="bg-brand-nav text-brand-nav-foreground flex items-center justify-between px-4 py-2 transition-colors duration-300">
<header className="light:bg-gray-100 light:text-black flex items-center justify-between bg-[#23262b] px-4 py-2 text-white transition-colors duration-300">
<WalletPreview
isLoading={isLoading}
isReloading={isReloading}
walletName={walletName}
totalBalance={totalBalance}
rescanInfo={rescanInfo}
development={development}
/>
<div
className={cn(
'text-brand-nav-foreground/80 hidden min-w-0 flex-1 items-center justify-center text-sm font-semibold md:gap-6 lg:gap-12 lg:text-base',
'hidden min-w-0 flex-1 items-center justify-center text-sm font-semibold md:gap-6 lg:gap-12 lg:text-base',
{
'md:flex': !isSidebarOpen,
'lg:flex': isSidebarOpen,
@ -173,17 +163,20 @@ export function AppNavbar({
},
)}
>
<Link to={rescanningRoute ? '#' : routes.receive} className="hover:text-brand-nav-foreground">
<Link to={rescanningRoute ? '#' : routes.receive} className="text-muted-foreground hover:text-foreground">
{t('navbar.tab_receive')}
</Link>
<Link to={rescanningRoute ? '#' : routes.send} className="hover:text-brand-nav-foreground relative">
<Link to={rescanningRoute ? '#' : routes.send} className="text-muted-foreground hover:text-foreground relative">
<WithActivityIndicator active={singleCoinJoinRunning}>{t('navbar.tab_send')}</WithActivityIndicator>
</Link>
<Link to={rescanningRoute ? '#' : routes.earn} className="hover:text-brand-nav-foreground relative">
<Link to={rescanningRoute ? '#' : routes.earn} className="text-muted-foreground hover:text-foreground relative">
<WithActivityIndicator active={makerRunning}>{t('navbar.tab_earn')}</WithActivityIndicator>
</Link>
<span className="text-border select-none">|</span>
<Link to={rescanningRoute ? '#' : routes.sweep} className="hover:text-brand-nav-foreground relative">
<span className="text-gray-400 dark:text-gray-600">|</span>
<Link
to={rescanningRoute ? '#' : routes.sweep}
className="text-muted-foreground hover:text-foreground relative"
>
<WithActivityIndicator active={schedulerRunning}>{t('navbar.tab_sweep')}</WithActivityIndicator>
</Link>
</div>
@ -192,7 +185,7 @@ export function AppNavbar({
<Tooltip>
<TooltipTrigger asChild>
<Button
className="text-brand-success/80 hover:text-brand-success"
className="light:text-green-600 text-green-300"
variant="ghost-navbar"
size="icon"
onClick={() => void navigate(rescanningRoute)}
@ -201,14 +194,14 @@ export function AppNavbar({
<PackageSearchIcon className="motion-safe:animate-pulse" />
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('navbar.text_rescan_in_progress')}</TooltipContent>
<TooltipContent>{t('navbar.text_rescan_in_progress')}</TooltipContent>
</Tooltip>
)}
{joiningRoute && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="text-brand-success/80 hover:text-brand-success"
className="light:text-green-600 text-green-300"
variant="ghost-navbar"
size="icon"
onClick={() => void navigate(joiningRoute)}
@ -217,17 +210,10 @@ export function AppNavbar({
<ShuffleIcon className="motion-safe:animate-pulse" />
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('navbar.joining_in_progress')}</TooltipContent>
<TooltipContent>{t('navbar.joining_in_progress')}</TooltipContent>
</Tooltip>
)}
<ThemeToggleButton
className="hidden sm:flex"
variant="ghost-navbar"
theme={theme}
onClick={toggleTheme}
tooltipSide={tooltipSide}
/>
<ThemeToggleButton className="hidden sm:flex" variant="ghost-navbar" theme={theme} onClick={toggleTheme} />
<Tooltip>
<TooltipTrigger asChild>
@ -241,43 +227,38 @@ export function AppNavbar({
<SettingsIcon />
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('navbar.menu_mobile_settings')}</TooltipContent>
<TooltipContent>{t('navbar.menu_mobile_settings')}</TooltipContent>
</Tooltip>
{!serviceRunning && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="hidden sm:flex"
variant="ghost-navbar"
size="icon"
onClick={() => void lockWalletMutation.mutateAsync({ navigate, t })}
aria-label={t('settings.button_lock_wallet')}
disabled={lockWalletMutation.isPending}
>
{lockWalletMutation.isPending ? <Spinner /> : <LockKeyholeIcon />}
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('settings.button_lock_wallet')}</TooltipContent>
</Tooltip>
)}
{serviceRunning && (
<Tooltip>
<TooltipTrigger asChild>
<Button
className="hidden sm:flex"
variant="ghost-navbar"
size="icon"
onClick={() => void logoutMutation.mutateAsync({ navigate })}
aria-label={t('navbar.button_logout')}
disabled={logoutMutation.isPending}
>
{logoutMutation.isPending ? <Spinner /> : <LogOutIcon />}
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>{t('navbar.button_logout')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
className="hidden sm:flex"
variant="ghost-navbar"
size="icon"
onClick={() => void lockWalletMutation.mutateAsync({ navigate, t })}
aria-label={t('settings.button_lock_wallet')}
disabled={lockWalletMutation.isPending}
>
{lockWalletMutation.isPending ? <Spinner /> : <LockKeyholeIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.button_lock_wallet')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="hidden sm:flex"
variant="ghost-navbar"
size="icon"
onClick={() => void logoutMutation.mutateAsync({ navigate })}
aria-label={/* TODO: i18n */ 'Logout'}
>
<LogOutIcon />
</Button>
</TooltipTrigger>
<TooltipContent>{/* TODO: i18n */ 'Logout'}</TooltipContent>
</Tooltip>
{sidebarTrigger}
</div>
</header>

View file

@ -77,9 +77,6 @@ vi.mock('@/hooks/useFeatures', () => ({
vi.mock('@/store/jamSettingsStore', () => ({
useDeveloperMode: () => ({ enabled: mocks.developerMode }),
usePreviewFeatures: () => ({
'tx-history': true,
}),
}))
vi.mock('../dev/DevBadge', () => ({
@ -101,22 +98,22 @@ describe('AppSidebar', () => {
render(<AppSidebar side="left" />)
expect(screen.getByText('sidebar.title')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_home.label')).toBeInTheDocument()
expect(screen.getByText('navbar.title')).toBeInTheDocument()
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_receive')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_send')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_earn')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_earn_report.label')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_orderbook.label')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_settings.label')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_rescan.label')).toBeInTheDocument()
expect(screen.queryByText('sidebar.item_logs.label')).not.toBeInTheDocument()
expect(screen.getByText('Earn Report')).toBeInTheDocument()
expect(screen.getByText('Orderbook')).toBeInTheDocument()
expect(screen.getByText('navbar.menu_mobile_settings')).toBeInTheDocument()
expect(screen.getByText('settings.rescan_chain')).toBeInTheDocument()
expect(screen.queryByText('settings.show_logs')).not.toBeInTheDocument()
expect(screen.queryByText('Development')).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('global.close'))
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('sidebar.item_tour.label'))
fireEvent.click(screen.getByText('Tour'))
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(2)
expect(onTour).toHaveBeenCalledTimes(1)
@ -131,7 +128,7 @@ describe('AppSidebar', () => {
render(<AppSidebar side="right" />)
expect(screen.getByText('sidebar.item_logs.label')).toBeInTheDocument()
expect(screen.getByText('settings.show_logs')).toBeInTheDocument()
expect(screen.getByText('Development')).toBeInTheDocument()
expect(screen.getByText('Dev Page')).toBeInTheDocument()
expect(screen.getByText('Dev Setup')).toBeInTheDocument()

View file

@ -6,7 +6,6 @@ import {
CurlyBracesIcon,
DownloadIcon,
HandCoinsIcon,
HistoryIcon,
LogsIcon,
MilkIcon,
NotebookTabsIcon,
@ -41,80 +40,68 @@ import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures'
import { POST_LOGIN_TOUR_EVENT } from '@/constants/onboarding'
import { routes } from '@/constants/routes'
import { useFeatures } from '@/hooks/useFeatures'
import { useDeveloperMode, usePreviewFeatures } from '@/store/jamSettingsStore'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { DevBadge } from '../dev/DevBadge'
import { Badge } from '../ui/badge'
export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>, 'side'>) {
const { t } = useTranslation()
const { toggleSidebar } = useSidebar()
const { enabled: isDeveloperMode } = useDeveloperMode()
const previewFeatures = usePreviewFeatures()
const { isFeatureEnabled } = useFeatures()
const mainItems = useMemo(
() => [
{
title: t('sidebar.item_home.label'),
title: /*TODO: i18n t('sidebar.item_home.label')*/ 'Home',
url: routes.home,
icon: WalletIcon,
},
{
title: t('navbar.tab_receive'),
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_receive'),
url: routes.receive,
icon: DownloadIcon,
},
{
title: t('navbar.tab_send'),
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_send'),
url: routes.send,
icon: UploadIcon,
},
{
title: t('navbar.tab_earn'),
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_earn'),
url: routes.earn,
icon: HandCoinsIcon,
subitems: [
{
title: t('sidebar.item_earn_report.label'),
title: /*TODO: i18n t('sidebar.item_earn_report.label')*/ 'Earn Report',
url: routes.earnReport,
icon: NotebookTabsIcon,
},
],
},
{
title: t('navbar.tab_sweep'),
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_sweep'),
url: routes.sweep,
icon: BrushCleaningIcon,
},
{
title: t('sidebar.item_orderbook.label'),
title: /*TODO: i18n t('sidebar.item_orderbook.label')*/ 'Orderbook',
url: routes.orderbook,
icon: BookOpenIcon,
},
{
title: t('sidebar.item_jars.label'),
title: /*TODO: i18n t('sidebar.item_jars.label')*/ 'Jars',
url: routes.walletJarsDetails,
icon: MilkIcon,
},
...(previewFeatures?.['tx-history'] !== true
? []
: [
{
title: t('sidebar.item_history.label'),
url: routes.txHistory,
icon: HistoryIcon,
preview: true,
},
]),
],
[t, previewFeatures],
[t],
)
const settingsItems = useMemo(
() => [
{
title: t('sidebar.item_tour.label'),
title: /*TODO: i18n t('sidebar.item_tour.label')*/ 'Tour',
url: routes.home,
icon: SparklesIcon,
onClick: () => {
@ -123,7 +110,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
},
},
{
title: t('sidebar.item_rescan.label'),
title: /*TODO: i18n t('sidebar.item_rescan.label')*/ t('settings.rescan_chain'),
url: routes.rescan,
icon: PackageSearchIcon,
},
@ -131,7 +118,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
? []
: [
{
title: t('sidebar.item_logs.label'),
title: /*TODO: i18n t('sidebar.item_settings.label')*/ t('settings.show_logs'),
url: routes.logs,
icon: LogsIcon,
},
@ -180,7 +167,10 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<Sidebar side={side} variant="sidebar" collapsible="offcanvas">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>{t('sidebar.title')}</SidebarGroupLabel>
<SidebarGroupLabel>
{/*TODO: i18n t('sidebar.title')*/}
{t('navbar.title')}
</SidebarGroupLabel>
<SidebarGroupAction className="cursor-pointer" title={t('global.close')} onClick={() => toggleSidebar()}>
<XIcon />
<span className="sr-only">{t('global.close')}</span>
@ -193,7 +183,6 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
{item.preview ? <Badge variant="muted">{/* TODO: i18n */ 'Preview'}</Badge> : null}
</Link>
</SidebarMenuButton>
{item.subitems?.length && (
@ -223,7 +212,8 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<SidebarMenuButton asChild>
<Link to={routes.settings}>
<SettingsIcon />
<span>{t('sidebar.item_settings.label')}</span>
{/*TODO: i18n t('sidebar.item_settings.label')*/}
<span>{t('navbar.menu_mobile_settings')}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuSub>

View file

@ -12,6 +12,6 @@ describe('AuthPageShell', () => {
const child = screen.getByTestId('child')
expect(child).toBeInTheDocument()
expect(child.parentElement).toHaveClass('from-background', 'to-muted', 'flex', 'flex-1')
expect(child.parentElement).toHaveClass('from-background', 'to-muted', 'flex', 'min-h-screen')
})
})

View file

@ -2,7 +2,7 @@ import type { PropsWithChildren } from 'react'
export const AuthPageShell = ({ children }: PropsWithChildren) => {
return (
<div className="from-background to-muted flex flex-1 items-start justify-center bg-gradient-to-br p-4 sm:items-center">
<div className="from-background to-muted flex min-h-screen items-center justify-center bg-gradient-to-br p-4">
{children}
</div>
)

View file

@ -1,8 +1,7 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { useQueryJamInfo } from '@/hooks/useQueryJamInfo'
import { parseSemanticVersion, UNKNOWN_VERSION, type WalletFileName } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
import { Layout } from './Layout'
type SessionState = {
@ -17,14 +16,12 @@ const mocks = vi.hoisted(() => ({
pathname: '/',
setTheme: vi.fn(),
theme: 'dark',
backendInfo: undefined as ReturnType<typeof useQueryJamInfo> | undefined,
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
Trans: ({ i18nKey, children }: { i18nKey?: string; children?: React.ReactNode }) => children ?? i18nKey ?? null,
}))
vi.mock('react-router-dom', () => ({
@ -91,21 +88,19 @@ vi.mock('@/components/layout/AppNavbar', () => ({
vi.mock('@/components/layout/AppFooter', () => ({
AppFooter: ({
blockHeight,
backendVersion,
backendName,
joinmarketVersion,
onClickCheatsheet,
onClickLogs,
onClickOrderbook,
}: {
blockHeight?: number
backendVersion?: { raw?: string }
backendName?: string
joinmarketVersion?: string
onClickCheatsheet: () => void
onClickLogs?: () => void
onClickOrderbook: () => void
}) => (
<footer>
footer;blockHeight:={blockHeight};backenVersion:={backendVersion?.raw};backendName:={backendName}
footer:{blockHeight}:{joinmarketVersion}
<button type="button" onClick={onClickCheatsheet}>
open-cheatsheet
</button>
@ -171,15 +166,10 @@ vi.mock('@/hooks/useFeatures', () => ({
}),
}))
vi.mock('@/hooks/useQueryJamInfo', () => ({
useQueryJamInfo: () => {
return (
mocks.backendInfo ?? {
backendName: 'mocked',
backendVersion: UNKNOWN_VERSION,
}
)
},
vi.mock('@/hooks/useQueryJmInfo', () => ({
useQueryJmInfo: () => ({
version: 'jm-version',
}),
}))
vi.mock('@/components/ui/jam/Cheatsheet', () => ({
@ -213,7 +203,6 @@ describe('Layout', () => {
mocks.navigate.mockReset()
mocks.onCheatsheetOpenChange.mockReset()
mocks.setTheme.mockReset()
mocks.backendInfo = undefined
})
it('wires navbar, footer actions, overlays, and shortcuts', async () => {
@ -227,7 +216,8 @@ describe('Layout', () => {
)
expect(screen.getByText('page-content')).toBeInTheDocument()
expect(screen.getByText('footer;blockHeight:=123;backenVersion:=unknown;backendName:=mocked')).toBeInTheDocument()
expect(screen.getByText('navbar:dark:test-wallet:9876:123')).toBeInTheDocument()
expect(screen.getByText('footer:123:jm-version')).toBeInTheDocument()
expect(screen.getByText('sidebar:right')).toBeInTheDocument()
expect(screen.getByText('tour-enabled:true')).toBeInTheDocument()
@ -255,20 +245,6 @@ describe('Layout', () => {
expect(screen.getByText('logs-overlay:false')).toBeInTheDocument()
})
it('uses standalone backend label with native version fallback when standalone version is not semantic', () => {
mocks.backendInfo = { backendName: 'joinmarket-ng', backendVersion: parseSemanticVersion('0.34.2') }
render(
<Layout walletFileName={walletFileName} onLogout={vi.fn()} onLockWallet={vi.fn()}>
<section>page-content</section>
</Layout>,
)
expect(
screen.getByText('footer;blockHeight:=123;backenVersion:=0.34.2;backendName:=joinmarket-ng'),
).toBeInTheDocument()
})
it('disables logs and onboarding when the route or feature state requires it', () => {
mocks.cheatsheetOpen = true
mocks.logsFeature = false

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