Compare commits

...

56 commits

Author SHA1 Message Date
Thebora Kompanioni
a1abd6c7e6
feat: add language selector to login page (#1431)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* chore: remove wrapper from language selector and move to ui module

* chore: rename SettingItem -> SettingsItem

* chore(settings): hover and pointer only for items with action

* feat: add language selector to login page
2026-08-13 11:53:27 +05:30
Anirudh Patwal
0b707f44fc
fix(a11y): make settings rows keyboard-accessible (#1430)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-12 10:55:54 +02:00
Aman Yadav
69423b6b1d
perf(context): optimize combinedUtxosHash from O(n^2) to O(n) (#1424) 2026-08-11 21:18:00 +02:00
Kishore B
d406a18456
feat: add orderbook availability pre-check for send and sweep (#1407) 2026-08-11 20:49:16 +02:00
Kishore B
047a8ab779
refactor(ui): consolidate Send and Sweep precondition alerts (#1402)
Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
2026-08-11 21:50:55 +05:30
Guflly
d9918b30b0
fix(earn): export complete report as CSV (#1426) 2026-08-11 17:49:57 +02:00
Parth
04c62c12fd
fix(fb): explain missing eligible UTXOs (#1408)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-11 09:53:42 +02:00
Parth
c832b80a2f
feat(earn): show own offer status in local orderbook (#1420) 2026-08-10 22:04:39 +02:00
theborakompanioni
1535a8148e
chore(build): fix lint warnings in form validation test
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-09 22:58:11 +02:00
GuTS805
6022ed3d41
fix(a11y): make balance visibility toggle keyboard-accessible (#1412) 2026-08-09 21:05:49 +02:00
Aman Yadav
8f9d8d5917
fix(validation): correctly handle zero block height in blockHeightField (#1414) 2026-08-09 20:57:31 +02:00
Anirudh Patwal
56541c5c29
fix(fb): re-freeze fidelity bond utxo when renew/move sweep fails (#1399) 2026-08-09 20:47:48 +02:00
Kishore B
b9f2730a72
feat(wallet): implement wallet transaction history (#1368)
* feat(wallet): implement transaction history feature

- Add useQueryWalletHistory hook to fetch paginated transaction history from daemon
- Add TxHistoryTable component to render sortable and expandable transaction rows
- Add TxHistoryContent container for handling loading states and batch pagination
- Add TxHistoryOverlay full-screen dialog for viewing complete history
- Integrate inline Recent Activity section into MainWalletPage
- Add translation keys
- Add comprehensive unit tests

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* Update src/i18n/locales/en/translation.json

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>

* feat(wallet): migrate transaction history to dedicated page and update dashboard view

- Move transaction history to top-level /wallet/history route and add sidebar link
- Refactor dashboard transaction history into a collapsible accordion section
- Auto-refetch transaction history on UTXO set hash () updates
- Fallback to raw string for unrecognized roles in
- Standardize i18n keys

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* style(wallet): update taker role badge variant to cj-change in TxHistoryTable

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* chore: hide tx history behind feature toggle

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>
Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
2026-08-09 20:44:07 +02:00
theborakompanioni
037bf95784
chore(dev): expose orderbook watcher for dev container 2026-08-09 20:37:53 +02:00
Kishore B
152793a11b
ui(send): improve active collaborative send info (#1413)
* refactor(send): improve active collaborative send alert

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* chore: remove schedulerRunning flag from send alert

* chore(storybook): add ActiveCollaborativeSendAlert story

* chore(ui): ActiveCollaborativeSendAlert is an Item component

* chore(ui): blur forms when maker/taker operation is running

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
2026-08-09 17:54:34 +02:00
Thebora Kompanioni
8eef1958a5
build(deps): update dependencies (#1416)
Some checks failed
Build / build (v26.5.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
@hookform/resolvers            5.5.7  →    5.5.8
 @storybook/addon-a11y         10.5.5  →   10.5.7
 @storybook/addon-docs         10.5.5  →   10.5.7
 @storybook/addon-vitest       10.5.5  →   10.5.7
 @storybook/react-vite         10.5.5  →   10.5.7
 @testing-library/user-event   14.6.1  →   14.6.3
 @types/react                 19.2.17  →  19.2.18
 @types/react-dom              19.2.3  →   19.2.4
 conventional-changelog         8.1.0  →    8.1.1
 eslint-plugin-storybook       10.5.5  →   10.5.7
 storybook                     10.5.5  →   10.5.7
 vite                           8.2.0  →    8.2.1

 @chromatic-com/storybook   5.2.1  →   5.3.0
 @hookform/resolvers        5.5.8  →   5.7.1
 @noble/hashes              2.2.0  →   2.3.0
 globals                   17.8.0  →  17.9.0
 lint-staged               17.2.0  →  17.3.0
 lucide-react              1.28.0  →  1.30.0
 react-hook-form           7.83.0  →  7.84.0
 typescript-eslint         8.65.0  →  8.66.0
2026-08-07 23:26:18 +05:30
Kishore B
28888dbb6d
refactor: externalize hardcoded polling intervals and delays (#1406)
Some checks failed
Build / build (v26.5.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
* refactor: externalize hardcoded polling intervals and delays

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

* refactor(constants): move schedule polling interval dev config to env.dev

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
2026-08-05 18:18:52 +05:30
Thebora Kompanioni
d0326db3e7
chore(ui): align buttons in logs and earn report (#1405)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* chore(report): align buttons

* chore: align search input and button rows in earn report and logs
2026-08-04 22:52:54 +05:30
GuTS805
9fb32af42f
fix: pin toLocaleString() calls to en-US locale (#1400)
* fix: pin toLocaleString() calls to en-US locale
Several number formatting call sites relied on the host machine's default
locale instead of a fixed one, causing inconsistent digit grouping in the
UI and non-deterministic test failures on non-US locales.

* fix: address review feedback, adapt tests instead of hardcoding locale in components
2026-08-04 22:32:48 +05:30
Thebora Kompanioni
4c2a886c6b
chore: print console warning (#1403)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-03 15:38:34 +05:30
Thebora Kompanioni
4a90f342ff
refactor: use specific address type in ambiguity check (#1404)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-08-03 01:03:04 +05:30
Thebora Kompanioni
c70a8892a6
chore(build): remove console.debug from prod build (#1401) 2026-08-03 01:00:28 +05:30
Ash
51a23b4c5a
fix(validation): distinguish testnet and regtest for bech32 addresses (#1395)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* fix(validation): distinguish testnet and regtest for bech32 addresses

Base58 addresses (p2pkh/p2sh) genuinely collapse between testnet and regtest
because they share version bytes, so bitcoin-address-validation always labels
them testnet - the interchangeable fallback is correct for those. Bech32
addresses, however, carry a distinct HRP (tb1 vs bcrt1) and are already
identified per-network, so gate the fallback on !bech32. This stops a bcrt1
(regtest) address passing on a testnet wallet, and vice versa.

Follow-up to #1355.

* docs(test): correct base58 address type in comment (P2PKH, not P2SH)

* test(validation): pin testnet/regtest separation for bech32m taproot addresses
2026-08-02 13:53:24 +05:30
theborakompanioni
1c23525da5
chore(pre-release): v2.0.0-beta.2
Some checks failed
Deploy Storybook / deploy (push) Waiting to run
Build / build (v26.5.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
2026-07-31 23:34:03 +02:00
theborakompanioni
471442ad80
chore: improve texts and add more tests for precondition alerts 2026-07-31 23:26:09 +02:00
theborakompanioni
1a22e97cfb
chore(earn): precondition alerts with non-frozen fb warning 2026-07-31 22:44:41 +02:00
theborakompanioni
6d7b0362e3
chore: send/sweep precondition alerts with non-frozen fb warning 2026-07-31 21:13:51 +02:00
theborakompanioni
25246e1690
chore(fb): freeze utxo after creating fb 2026-07-31 17:33:25 +02:00
theborakompanioni
edf97c30a7
chore(fb): freeze utxo after renewing fb 2026-07-31 16:34:17 +02:00
theborakompanioni
f7f4de6ab3
refactor: wrap fidelity bond sweep in mutation 2026-07-31 15:54:45 +02:00
theborakompanioni
0c77dc37aa
chore: allow freezing/unfreezing of fidelity bonds 2026-07-31 13:27:57 +02:00
theborakompanioni
7988595766
test: change mainnet test address placeholders 2026-07-31 13:27:57 +02:00
theborakompanioni
2b69ef3dbb
test(send): more send form schema tests 2026-07-31 13:27:56 +02:00
theborakompanioni
150e880040
fix(send): disallow send from a jar with unfrozen fidelity bond 2026-07-31 13:27:56 +02:00
theborakompanioni
d8e6d6aa15
chore: define error type for manual coin control needed 2026-07-31 13:27:56 +02:00
Thebora Kompanioni
e0d7479711
build(deps): update dependencies (#1390)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* build(deps): update dependencies

 @vitejs/plugin-react   6.0.4  →   6.0.5
 lucide-react          1.26.0  →  1.28.0
 react-router-dom      7.18.1  →  7.18.2
 vite                   8.1.5  →   8.2.0
 @storybook/addon-a11y    10.5.4  →  10.5.5
 @storybook/addon-docs    10.5.4  →  10.5.5
 @storybook/addon-vitest  10.5.4  →  10.5.5
 @storybook/react-vite    10.5.4  →  10.5.5
 @types/node              26.1.1  →  26.1.2
 eslint-plugin-storybook  10.5.4  →  10.5.5
 storybook                10.5.4  →  10.5.5
 @playwright/test           1.61.1  →  1.62.0
 @testing-library/jest-dom   6.9.1  →  6.10.0
 globals                    17.7.0  →  17.8.0

* build: fix vitest warnings after update

* build(deps): update @hookform/resolvers to v5.5.7

* build(deps): update msw-storybook-addon to v3.0.0

note: still uses a deprecated feature as the migration path did not work properly

* build(dev-deps): update jsdom to v30.0.1
2026-07-30 21:25:10 +02:00
Thebora Kompanioni
02115e808a
chore(ci): pin ci actions (#1389) 2026-07-30 21:23:08 +02:00
Brian Soltani
9346804bf8
fix(wallet): keep jars visible and clickable during background refetch (#1392)
* fix(wallet): keep jars visible and clickable during background refetch

Gate the jars grid on isLoading instead of isFetching, matching the balance display above it. isFetching is true on every background refetch, so the jars (and the Jar Details overlay they open) were being replaced by a spinner any time wallet data refreshed, even though the previous jars were still valid to show.

* test(wallet): assert jars stay visible during background refetch

Update the jars spinner test to match the new isLoading-based gate. The test now asserts that a jar name stays on screen and the background refetch still calls refetch on click, instead of asserting a spinner appears.

* test(wallet): assert jar overlay opens on click during background refetch
2026-07-30 23:13:42 +05:30
Thebora Kompanioni
306e6b83d1
chore(orderbook): improve bond and relative fee display value (#1384)
* chore(orderbook): improve bond and relative fee display value

* ui(earn): hover effects on fidelity bond and offer cards

* fix: use correct repo url base

* chore(jar-detials): improve confirmations display value
2026-07-30 16:52:44 +02:00
Thebora Kompanioni
59e959da97
fix(earn): disable fidelity bond actions while rescanning (#1373) 2026-07-30 16:52:26 +02:00
Thebora Kompanioni
bce1df33e9
chore(send): jars with frozen funds are selectable (#1370)
* chore(send): remove dev/prod distinction for minimum collaborators

this is already dynamically done by fetching the backend config value

* chore(send): jars with frozen funds are selectable

* ui(send): justify jars and increase space to precondition alert

* build: fix errors in test setups

* feat(send): open utxo selector on click on selected jar

* test: add tests for Jar and SelectableJar

* chore(ui): frozen balance with class 'invisible' instead of 'hidden'

* ui(receive): justify around jars

* chore(i18n): improve missing source jar utxos message
2026-07-30 11:35:19 +02:00
Thebora Kompanioni
d5b38740c7
fix: disable freeze/unfreeze when maker/taker/rescan is active (#1379)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-07-30 01:09:50 +05:30
Thebora Kompanioni
864e976772
ui(xpub): colored labels on xpub dialog (#1372) 2026-07-29 19:50:06 +05:30
Parth
ff0a9f229e
fix(receive): block new addresses during rescan (#1367)
* fix(receive): block new addresses during rescan

* test(receive): click disabled address button
2026-07-29 16:50:38 +05:30
Thebora Kompanioni
6490a96714
fix(receive): save sourceJarIndex before async operation (#1369)
Some checks failed
Build / build (v26.5.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
* fix(receive): save sourceJarIndex before async operation

* chore: flag if address from parsed from bip21 uri

* chore: scan applied alert only when parsed from uri
2026-07-29 09:18:20 +02:00
Parth
0e24250e3d
refactor: standardize remaining dialog forms (#1358)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* refactor(earn): standardize move-to-jar form

* refactor(earn): standardize bond renewal form

* refactor(send): standardize utxo selection form

* chore(settings): remove stale form migration todo

* refactor(earn): rely on form confirmation default
2026-07-28 13:27:08 +05:30
Thebora Kompanioni
01a952ff63
refactor: improve balance summary calculation (#1350)
* chore: store bonded balance in BalanceSummary

* refactor: improve balance summary calculation
2026-07-27 14:48:12 +05:30
Parth
fc1a02cead
refactor(send): standardize jar selector form handling (#1357)
* refactor(send): add jar selector form schema

* refactor(send): use react-hook-form for jar selection
2026-07-27 14:17:04 +05:30
Ash
82cc4762ff
fix(sweep): validate destination addresses against wallet network (#1355)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-07-26 21:03:31 +02:00
Thebora Kompanioni
8789e63798
chore: raise coverage thresholds (#1356) 2026-07-26 21:14:44 +05:30
Thebora Kompanioni
3e7f5ea41f
build(deps): update node to v26.5.0 (#1353) 2026-07-26 20:02:42 +05:30
theborakompanioni
c41b40bec9 dev(regtest): fund jar 0, 1 and 4 of dev wallet 2026-07-26 15:42:24 +02:00
theborakompanioni
fe43741a37 build(deps): update radix-ui components 2026-07-26 15:42:24 +02:00
theborakompanioni
3f0d3bbc40 build(deps): update storybook and eslint 2026-07-26 15:42:24 +02:00
theborakompanioni
5f0818d734 build(deps): update dev dependencies
lint-staged                  17.0.8  →  17.2.0
 prettier                      3.9.5  →   3.9.6
 prettier-plugin-tailwindcss   0.8.0  →   0.8.1
 typescript-eslint            8.63.0  →  8.65.0
2026-07-26 15:42:24 +02:00
theborakompanioni
6698c6d574 build(deps): update dependencies
@tailwindcss/vite                4.3.2  →    4.3.3
 @tanstack/eslint-plugin-query  5.101.2  →  5.101.4
 @tanstack/react-query          5.101.2  →  5.101.4
 @vitejs/plugin-react             6.0.3  →    6.0.4
 lucide-react                    1.24.0  →   1.26.0
 react                           19.2.7  →   19.2.8
 react-dom                       19.2.7  →   19.2.8
 react-hook-form                 7.81.0  →   7.83.0
 react-i18next                   17.0.9  →  17.0.11
 tailwindcss                      4.3.2  →    4.3.3
 vite                             8.1.4  →    8.1.5
2026-07-26 15:42:24 +02:00
158 changed files with 8243 additions and 2816 deletions

5
.env
View file

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

View file

@ -13,3 +13,5 @@ VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=1
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JAM_SWEEP_MIN_MIN_NUMBER_OF_COLLABORATORS=1
VITE_JAM_RUNNING_SCHEDULE_POLLING_INTERVAL=5000

View file

@ -3,3 +3,5 @@ VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_MIN_TIMEOUT_SECONDS=1
VITE_JAM_SWEEP_MAKER_SESSION_IDLE_TIMEOUT_SECONDS=1
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1

View file

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

View file

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

View file

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

View file

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

2
.nvmrc
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

3050
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { type Jar as JarType } from '@/context/JamWalletInfoContext'
import type { AmountSats } from '@/types/global'
import { Jar } from './Jar'
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString, onClick }: { valueString: string; onClick?: () => void }) => (
<span onClick={onClick}>balance:{valueString}</span>
),
}))
const DUMMY_JAR: JarType = {
balanceSummary: {
calculatedTotalBalanceInSats: 5,
calculatedAvailableBalanceInSats: 4,
calculatedConfirmedAvailableBalanceInSats: 3,
calculatedAvailableFrozenBalanceInSats: 2,
calculatedFrozenOrLockedBalanceInSats: 1,
},
color: '#666',
jarIndex: 0,
name: 'Test jar',
utxos: [],
}
describe('Jar', () => {
it('renders correctly', () => {
render(
<Jar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
/>,
)
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
expect(
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats),
).toBeInTheDocument()
expect(
screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedFrozenOrLockedBalanceInSats).parentNode,
).not.toHaveClass('invisible')
})
it('does not render zero frozen balance', () => {
const frozenBalance: AmountSats = 0
render(
<Jar
name={DUMMY_JAR.name}
color={DUMMY_JAR.color}
frozenOrLockedBalance={frozenBalance}
totalBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats}
totalWalletBalance={DUMMY_JAR.balanceSummary.calculatedTotalBalanceInSats + 1}
isSelected={false}
/>,
)
expect(screen.getByText(DUMMY_JAR.name)).toBeInTheDocument()
expect(screen.getByText('balance:' + DUMMY_JAR.balanceSummary.calculatedAvailableBalanceInSats)).toBeInTheDocument()
expect(screen.getByText('balance:' + frozenBalance)).toBeInTheDocument()
expect(screen.getByText('balance:' + frozenBalance).parentNode).toHaveClass('invisible')
})
})

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