Compare commits

...

117 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
theborakompanioni
b4d7c9228c
chore(pre-release): v2.0.0-beta.1
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-07-25 04:53:48 +02:00
Parth
5ae6a18e35
Rework the Fidelity Bond modals (create, renew, unlock) (#1345)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* fix: generate valid past lockdate options in developer mode

* fix: keep dialog scrollbar clear of step content

* feat: allow creating additional fidelity bonds in developer mode

* chore: align jars and improve bond wording

* chore(fb): use Item component in StepIntro

* chore(ui): update Card component

* chore(earn): always show action buttons for expired fidelity bonds

* chore: remove obsolete responsive test for Card component

---------

Co-authored-by: theborakompanioni <theborakompanioni+github@gmail.com>
2026-07-25 02:19:38 +02:00
theborakompanioni
848e7ef825 fix(ui): scrollable area in WalletJarDetails 2026-07-24 23:22:19 +02:00
theborakompanioni
dfcea24b79 chore(storybook): add story RescanInProgressWithoutPercentage 2026-07-24 23:22:19 +02:00
theborakompanioni
665889d856 chore(navbar): add development flag to AppNavbar 2026-07-24 23:22:19 +02:00
theborakompanioni
07b8a4b12e chore(receive): reduce address loading blur 2026-07-24 23:22:19 +02:00
theborakompanioni
b221f3a77a chore(eslint): enforce lowercase hexadecimal literals 2026-07-24 23:22:19 +02:00
theborakompanioni
0eecb9444f refactor: replace deprecated toThrowError with toThrow 2026-07-24 23:22:19 +02:00
theborakompanioni
c22586b9a4 test: add missing tests for feeEstimate, ErrorPage and useQueryYieldgenReport 2026-07-24 23:22:19 +02:00
theborakompanioni
c881f21fb5 chore(test): ignore main.tsx from coverage 2026-07-24 23:22:19 +02:00
theborakompanioni
af09c7b0b9 test: verify dialog close in MainWalletView 2026-07-24 23:22:19 +02:00
theborakompanioni
a020635089 refactor: derive key only once in AccountXpubsDialog 2026-07-24 23:22:19 +02:00
theborakompanioni
aba28c0a6f test: init tests for contexts 2026-07-24 23:22:19 +02:00
theborakompanioni
1433698eb4 refactor: move uint8ArrayfromHex to utils 2026-07-24 23:22:19 +02:00
Thebora Kompanioni
b90d5b2843
chore(ui): use FieldError component consistently (#1348)
* chore: freeze/unfreeze alert warning or error depending on context

* chore(sweep): fieldset and errors in sweep form

* chore(form): use FieldError component consistently

* chore(cleanup): remove unnecessary mocks

* test(websocket): initial test for useJmWebsocket
2026-07-24 18:04:58 +02:00
Kishore B
51d843c03a
feat(ui): show backend-specific warnings in onboarding and footer info modals (#1341)
* feat(ui): joinmarket backend information in info modals

* chore(i18n): remove joinmarket-clientserver specific texts

* refactor: add constants JAM_REPO_URL, JAM_DOCS_URL, JAM_JMNG_REPO_URL

* chore(ui): add third splashscreen description line

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
2026-07-24 15:25:17 +02:00
Thebora Kompanioni
64ecd7ad77
feat: plan scheduled sweep (#1339)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* refactor: add reason to scheduler error messages

* fix: dismiss schedule confirm dialog on error

* chore(i18n): distinct scheduler starting/stopping messages

* chore(sweep): remove redundant manual destination validation

* chore(sweep): initial sweep options

* chore(ui): add slider component

* chore(sweep): add rounding chance option

* feat(sweep): ability to add/remove destinations

* chore(sweep): use plan tumbler mutation

* refactor(sweep): use tumblerstartMutation

* refactor(sweep): externalize SweepForm

* feat(sweep): ability to set min/max number of collaborators

* chore(sweep): increase time_lambda_seconds in insecure test options

* chore(sweep): fix progress step of quick sweep

* chore(sweep): start refactor schedule entry

* chore(sweep): preserve raw data in ScheduleEntry

* feat(sweep): show destination addresses

* chore(sweep): scroll to top after start/stop

* chore(ui): add Item component

* fix: only abort single taker runs on send page

* chore(sweep): schedule entries with Item component

* chore(sweep): show wait time before next action

* chore(sweep): schedule entries in accordion

* chore(sweep): remove scheduler.progress_current_state_wait_before_next message

* chore(sweep): tab for completed entries

* refactor(sweep): align Schedule object structure

* chore(sweep): display error message on failed actions

* chore(sweep): display completed scheduled sweep

* chore(sweep): move stop button from child component to page

* feat(sweep): ability to review scheduled sweep plan

* chore(sweep): increase schedule polling from 3s to 10s

* chore(sweep): hourglass icon for waiting alerts

* chore(sweep): adapt buttons, labels and alert texts

* fix(sweep): default min confirmations between phases

* chore(sweep): improve time estimate on sweeps

* chore(sweep): show pending tasks in details

* feat(sweep): ability to configure mintxcount

* chore(storybook): add slider, sidebar and item stories

* test(sweep): verify add/remove sweep destination input

* test: load decorators from story meta config

* chore(sweep): add makeSessionIdleTimeoutSeconds to form values

* chore(sweep): reload schedule if not stale or terminated

* chore(sweep): more details in sections

* chore(sweep): reorder entry details

* chore(sweep): show transaction info in destination section

* chore(sweep): estimate duration with fixed runtime info of maker phases

* chore(regtest): setup dummy wallet fidelity bonds

* fix(sweep): maker idle timeout less than runtime duration

* chore(sweep): use taker_utxo_age as min confs in sweep preconditions

* chore(build): add task test:coverage

* chore(sweep): max number of destination addresses

* chore(sweep): maxNumberOfDestinations
2026-07-23 13:06:40 +02:00
Thebora Kompanioni
f9523729ae
chore(rescan): max rescan height is current blockheight (#1332)
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
* chore(rescan): max rescan height is current blockheight

* chore: generalize block height form input

* feat(rescan): quickly set start block height

* chore(rescan): optionally render back button

* chore: use constant for initial blockheight value calculation
2026-07-21 18:29:48 +02:00
Kishore B
4f8d7f497f
docs: replace joinmarket-clientserver references to joinmarket-ng (#1340)
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
* docs: replace joinmarket-clientserver references to joinmarket-ng

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

* docs: update api terminology to joinmarket-ng

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* docs: standardize JoinMarket NG naming

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

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 07:34:06 +05:30
theborakompanioni
a21857946b
build(deps): update package-lock.json
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-07-12 20:13:24 +02:00
theborakompanioni
419cdf1db0 chore(i18n): adapt tag tooltips 2026-07-12 18:21:46 +02:00
theborakompanioni
f7a1af9993 chore(ui): improve tx tags colors 2026-07-12 18:21:46 +02:00
Parth
b0b3a7d284
feat: display alert and reload balance on tx message via websocket (#1337)
* feat: display alert and reload balance on tx message via websocket

* test: satisfy required JmTxInfo fields in websocket provider test
2026-07-12 20:09:14 +05:30
theborakompanioni
2035dcf88e
chore(ui): all placeholder with sans-serif font 2026-07-12 14:25:34 +02:00
Parth Bandwal
6c2bf12d39 fix(ui): wrap table pagination row instead of cutting off buttons 2026-07-12 10:15:56 +02:00
Parth
0ae4c1d65f
i18n rework and dialog UX fixes (#1330)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* fix(ui): place cancel below primary action in dialogs on mobile

* chore(i18n): remove unused translation keys

* fix(i18n): add missing keys referenced in code

* dev(i18n): translate hardcoded status alerts and toasts

* dev(i18n): translate hardcoded navigation and misc UI strings

* dev(i18n): translate post-login onboarding tour

* dev(i18n): translate hardcoded strings in create, import, and login flows

* chore(i18n): fix misspelled mnemonic key names

* chore(i18n): reuse global button labels instead of per-dialog duplicates

* fix(i18n): replace english-text-as-key t() calls with real keys

* dev(i18n): apply review feedback on key structure

* fix(i18n): restore locale translations for reused fee-config error key

* fix(lint): remove eslint-disable directives for rules dropped in dep upgrade

* fix(lint): scope eslint-disable to cover all this/http violations in test mocks

* dev(i18n): translate address-reuse safety toasts and remaining stragglers

* fix(test): update useUtxoSelectionDialog assertion to translated toast keys

* dev(i18n): address review — add _title postfix and pluralize auto-selection toasts
2026-07-11 16:54:52 +05:30
theborakompanioni
acf3208236 chore(ui): add fonts JetBrains Mono and Inter 2026-07-11 11:01:34 +02:00
theborakompanioni
2b52d182da chore(ui): reset fonts to system or default 2026-07-11 11:01:34 +02:00
Thebora Kompanioni
91901ae106
build(deps): update dependencies (#1335)
* build(deps): update dependencies

 @storybook/addon-a11y    10.4.6  →  10.5.0
 @storybook/addon-docs    10.4.6  →  10.5.0
 @storybook/addon-vitest  10.4.6  →  10.5.0
 @storybook/react-vite    10.4.6  →  10.5.0
 conventional-changelog    7.2.1  →   8.1.0
 eslint-plugin-storybook  10.4.6  →  10.5.0
 lucide-react             1.23.0  →  1.24.0
 prettier                  3.9.4  →   3.9.5
 react-i18next            17.0.8  →  17.0.9
 storybook                10.4.6  →  10.5.0
 vite                      8.1.3  →   8.1.4
 @playwright/test            1.61.0  →  1.61.1
 @vitest/browser              4.1.9  →  4.1.10
 @vitest/browser-playwright   4.1.9  →  4.1.10
 @vitest/coverage-v8          4.1.9  →  4.1.10
 vitest                       4.1.9  →  4.1.10

* chore(dev): fail fast on pre-push tests

* chore(dev): re-init mockServiceWorker.js with v2.15.0
2026-07-11 12:16:26 +05:30
theborakompanioni
2ebee2e8ee chore(ui): colorize selected jar name
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-07-10 19:55:54 +02:00
theborakompanioni
59dc044423 ui(send): align input group clear buttons on mobile 2026-07-10 19:55:54 +02:00
theborakompanioni
bcabd99867 chore: address reveal button in color of jar 2026-07-10 19:55:54 +02:00
theborakompanioni
492ae14dc1 chore(ui): add jar button variants 2026-07-10 19:55:54 +02:00
theborakompanioni
5041ce6af5 ui(receive): increase font size of address 2026-07-10 19:55:54 +02:00
theborakompanioni
6c7d2878db fix: apply className to container in address component 2026-07-10 19:55:54 +02:00
theborakompanioni
8197587691 ui(receive): move options inside card component 2026-07-10 19:55:54 +02:00
theborakompanioni
31cc13b08a ui(navbar): adjust wallet preview loading indicator color 2026-07-10 19:55:54 +02:00
theborakompanioni
ff6796f94c ui(style): align sidebar styles 2026-07-10 19:55:54 +02:00
theborakompanioni
49f2c888c9 ui(navbar): navbar tooltip position and sidebar toggle text 2026-07-10 19:55:54 +02:00
theborakompanioni
dfe7807689 chore(ui): navbar link colors 2026-07-10 19:55:54 +02:00
Thebora Kompanioni
ecc39aa15d
chore(build): require min node level of v26.3.0 (#1333)
chore: raise min node level to v26.3.0

This is required in order to support NODE_OPTIONS=--no-webstorage (in tests)
2026-07-10 21:35:22 +05:30
Kishore B
7cf31890b2
refactor: migrate API client to joinmarket-ng-api-ts (#1305)
* chore(deps): migrate to joinmarket-ng-api-ts

- Uninstall legacy  (v0.3.0).
- Install new  (v1.0.0).
- Update package.json  for  to apply to the new ng-api package.

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

* refactor: update all api client imports to use joinmarket-ng-api-ts

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

* refactor(types): replace ErrorMessage with unknown for ng-api compatibility

- Remove ErrorMessage type imports from @joinmarket-webui/joinmarket-ng-api-ts/jm across the codebase.
- Update error types in React Query hooks (useQuery, useMutation) and context states to use unknown instead of ErrorMessage.
- Update error handler callbacks (onError) to accept unknown type, adapting to the error type changes in the generated ng-api client.

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

* fix(types): handle nullable fields from ng-api client

- Add nullish/undefined checks for sessionInfo.wallet_name in create, import, and login flows.
- Handle nullable progress field in rescanInfo by checking != null and defaulting to undefined.
- Fallback block_height to undefined if nullish in layout and session contexts.
- Default walletInfo.accounts to an empty array to prevent mapping errors if nullish.

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

* fix(types): fix typescript errors from ng-api transition

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

* fix(lint): resolve typescript-eslint errors in tests and providers

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

* feat: redesign sweep to tumbler api with minimal adaptations

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

* pin api-ts deps and validate rescan progress value

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

* simplify active session check in import flows and harden report parsing

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

* refactor: fastapi error parsing and SweepPage ux

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

* fix: set VITE_JM_API_BASE_URL to empty for joinmarket-ng-api-ts
-Endpoints generated by the new client package already include the
'/api/v1' path prefix in their definitions.

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

* refactor(ui): use session state as single source of truth for active wallets

- Use  as the sole condition to display the active session warning in wallet create/import flows, rather than relying on the presence of a .
- Update  to dynamically fall back to a localized placeholder ('another wallet') when  is missing.
- Add  fallback key to English translation file.

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

* style: format create, import files

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

* refactor(earn): support only ng backend format in yieldgen report hook

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

* refactor: improve active wallet warning translations

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

* fix(sweep): use includes instead of repeated equality checks for lint check

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

* refactor(sweep): align phase completion check strictly with ng backend PhaseStatus

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

---------

Signed-off-by: kishore08-07 <kishorebsm8@gmail.com>
2026-07-10 20:04:16 +05:30
Parth
a6ad27f743
fix(send): make utxo selection table scrollable (#1327) 2026-07-10 15:10:39 +02:00
Thebora Kompanioni
7be8d4863a
chore: add csp meta tag (#1325)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* chore: add csp meta tag

* chore: add base-uri and form-action to csp tag

Co-authored-by: Kishore B <kishorebsm8@gmail.com>

---------

Co-authored-by: Kishore B <kishorebsm8@gmail.com>
2026-07-09 14:06:14 +05:30
Parth
17c6a0d049
fix(send): remove input group artifacts on selected jar fields (#1328)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
2026-07-08 20:24:44 +02:00
Thebora Kompanioni
6a34868e2b
build(deps): update dependencies (#1322)
* build(deps): update dependencies

 @radix-ui/react-accordion      1.2.14  →  1.2.16
 @radix-ui/react-avatar          1.2.0  →   1.2.2
 @radix-ui/react-checkbox        1.3.5  →   1.3.7
 @radix-ui/react-dialog         1.1.17  →  1.1.19
 @radix-ui/react-dropdown-menu  2.1.18  →  2.1.20
 @radix-ui/react-label          2.1.10  →  2.1.11
 @radix-ui/react-popover        1.1.17  →  1.1.19
 @radix-ui/react-radio-group     1.4.1  →   1.4.3
 @radix-ui/react-select          2.3.1  →   2.3.3
 @radix-ui/react-separator      1.1.10  →  1.1.11
 @radix-ui/react-switch          1.3.1  →   1.3.3
 @radix-ui/react-tabs           1.1.15  →  1.1.17
 @radix-ui/react-tooltip        1.2.10  →  1.2.12
 @types/node                    26.0.1  →  26.1.1
 conventional-changelog          7.2.0  →   7.2.1
 lucide-react                   1.22.0  →  1.23.0
 msw                            2.14.6  →  2.15.0
 prettier                        3.9.3  →   3.9.4
 radix-ui                        1.6.0  →   1.6.2
 react-hook-form                7.80.0  →  7.81.0
 typescript-eslint              8.62.1  →  8.63.0
 vite                            8.1.0  →   8.1.3

* build(deps): update eslint-plugin-unicorn to v65.0.1
2026-07-08 23:07:57 +05:30
Parth
1b056416b8
feat(navbar): toggle lock wallet/logout button based on running services (#1318)
* feat(navbar): toggle lock wallet/logout button based on running services (#1311)

* fix(navbar): disable logout button while pending and localize its label

* fix(i18n): add navbar.button_logout translations for all locales
2026-07-08 21:36:41 +05:30
Parth
db1f7495ec
dev(i18n): improve user-facing copy across the app (#1320)
* dev(i18n): improve user-facing copy across the app

* Apply suggestion from @theborakompanioni

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

* dev(i18n): reflect no-spendable-balance copy in other locales

---------

Co-authored-by: Thebora Kompanioni <theborakompanioni@users.noreply.github.com>
2026-07-08 20:49:44 +05:30
Thebora Kompanioni
2494cc84fa
dev(ui): add beta warning header (#1319) 2026-07-08 13:29:40 +02:00
Kishore B
90a7d7203e
chore(ui): jar badges with their respective jar colors (#1308) 2026-07-08 10:34:54 +02:00
Thebora Kompanioni
290044e9d2
dev(regtest): fix regtest init of standalone-ng containers (#1321)
* dev(regtest): remove unnecessary env vars

* dev(regtest): expose jmwalletd port for init routine

* Fix typo in BITCOIN__RPC_PASSWORD to "regtest"

---------

Co-authored-by: Kishore B <kishorebsm8@gmail.com>
2026-07-08 10:37:26 +05:30
Thebora Kompanioni
147d815f3b
fix: correct rescan progress on Rescan page (#1313)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* dev(regtest): remove unnecessary env vars

* fix: correct rescan progress on Rescan page

* Revert "dev(regtest): remove unnecessary env vars"

This reverts commit f6ca1cbd4c.

* chore: progress in percentage as string
2026-07-08 00:37:23 +05:30
theborakompanioni
1b5d1cf6e6
Revert "dev(ui): add ugly beta warning header (#1317)"
This reverts commit e7f317aa49.
2026-07-07 16:49:03 +02:00
Thebora Kompanioni
e7f317aa49
dev(ui): add ugly beta warning header (#1317)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
Co-authored-by: Parth Bandwal <143504541+parrth20@users.noreply.github.com>
2026-07-06 21:29:59 +02:00
Parth
ff739fcc07
refactor(validation): share form field validators for source jar and bitcoin address (#1315)
* refactor(validation): add shared form field validators

* refactor(send): use shared source jar and destination address validators

* refactor(receive): use shared source jar validator and align amount bound

* refactor(earn): use shared source jar validator for fidelity bonds

* refactor(sweep): reuse shared bitcoin address predicates

* fix(validation): guard chained destination address tests and trim input
2026-07-06 23:44:21 +05:30
Parth
a486002a71
Improve component sizing for buttons and inputs (#1302)
* style: increase shared control sizing

* style: improve form control sizing

* style: broaden component sizing pass

* style: format component sizing updates

* fix: align grouped control sizing
2026-07-06 22:46:44 +05:30
Parth
d66f5e9c82
chore(ui): improve mobile responsiveness (#1292)
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
2026-07-05 15:54:37 +02:00
Parth
c7cb02ae86
fix: use sats-only amount placeholders (#1301)
Some checks failed
Build / build (v26.3.0) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
Deploy Storybook / deploy (push) Has been cancelled
* fix: use sats-only amount placeholders

* fix: update sats-only placeholders in locales
2026-06-30 17:09:18 +05:30
Thebora Kompanioni
eaadf56005
build(deps): update dependencies (#1303)
* build(deps): update dependencies

 @tailwindcss/vite                4.3.1  →    4.3.2
 @tanstack/eslint-plugin-query  5.101.0  →  5.101.2
 @tanstack/react-query          5.101.0  →  5.101.2
 @types/node                    24.12.4  →   26.0.1
 @vitejs/plugin-react             6.0.2  →    6.0.3
 globals                         17.6.0  →   17.7.0
 lint-staged                     17.0.7  →   17.0.8
 lucide-react                    1.20.0  →   1.22.0
 prettier                         3.8.4  →    3.9.3
 react-hook-form                 7.79.0  →   7.80.0
 react-router-dom                7.18.0  →   7.18.1
 tailwindcss                      4.3.1  →    4.3.2
 typescript-eslint               8.61.1  →   8.62.1
 vite                            8.0.16  →    8.1.0

* chore: format source
2026-06-30 13:17:15 +05:30
Parth
91d31c2639
Apply Tailwind styling consistency and brand identity pass across app (#1297)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* style: add jam brand theme tokens

* style: apply brand tokens to shared UI

* style: add semantic brand status tokens

* style: normalize branded UI states
2026-06-30 00:55:35 +05:30
Thebora Kompanioni
50562ba897
dev(regtest): rectify tor setup and upgrade to core v29 (#1298)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run
* dev(regtest): adapt bitcoin rpc users

* dev(regtest): update to bitcoind v30.0

* chore(regtest): create descriptor wallets for clientserver regtest containers

* chore(regtest): add backend descriptor to clientserver directory node

* chore(regtest): rectify tor setup and downgrade to core v29

* dev(regtest): set ENSURE_WALLET to false and remove unused DESCRIPTOR_WALLET_NAME for ng nodes
2026-06-28 14:22:36 +02:00
411 changed files with 19944 additions and 9205 deletions

16
.env
View file

@ -1,14 +1,28 @@
VITE_JAM_DEFAULT_THEME=dark
VITE_JAM_REPO_URL=https://github.com/joinmarket-webui/jam
VITE_JAM_DOCS_URL=https://jamdocs.org
VITE_JAM_MATRIX_URL=https://matrix.to/#/%23jam:bitcoin.kyoto
VITE_JAM_TELEGRAM_URL=https://t.me/JoinMarketWebUI
VITE_JAM_JMNG_REPO_URL=https://github.com/joinmarket-ng/joinmarket-ng
VITE_JM_API_BASE_URL=/api/v1
VITE_JM_API_BASE_URL=
VITE_JM_API_AUTH_TOKEN_EXPIRY_SECONDS=1800
VITE_JAM_JM_SESSION_REFRESH_INTERVAL=30000
VITE_JAM_RESCAN_PROGRESS_INTERVAL=10000
VITE_JAM_SEED_MODAL_TIMEOUT=30000
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_MIN_COUNT=3
VITE_JAM_SWEEP_DESTINATION_ADDRESSES_DEFAULT_COUNT=3
VITE_JM_WEBSOCKET_ENDPOINT_PATH=/jmws
VITE_JM_WEBSOCKET_CONNECTION_HEALTHY_DURATION=1000
VITE_JM_WEBSOCKET_CONNECTION_AUTHENTICATED_DURATION=3000
VITE_JM_WEBSOCKET_KEEPALIVE_MESSAGE_INTERVAL=30000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MIN=5000
VITE_JM_WEBSOCKET_RECONNECT_INTERVAL_MAX=60000
VITE_JAM_SWEEP_MIN_ROUNDING_CHANCE_PERCENT=10
VITE_JAM_SWEEP_MAX_ROUNDING_CHANCE_PERCENT=90
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS=true
VITE_JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY=1000

View file

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

7
.env.test Normal file
View file

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

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ['v26.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: |
@ -50,7 +50,7 @@ jobs:
run: npm run format:check
- name: Test
run: npm test -- --coverage.enabled
run: npm run test:coverage
- name: Build
run: npm run build
@ -59,4 +59,4 @@ jobs:
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in vite config
if: ${{ always() && hashFiles('coverage/coverage-summary.json') != '' }}
uses: davelosert/vitest-coverage-report-action@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

View file

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

2
.nvmrc
View file

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

View file

@ -2,11 +2,12 @@
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'
import { CoreTypes, GlobalTypes } from 'storybook/internal/csf'
import type { CoreTypes, GlobalTypes } from 'storybook/internal/csf'
import { DEFAULT_VIEWPORT, MINIMAL_VIEWPORTS, INITIAL_VIEWPORTS } from 'storybook/viewport'
import { JamDisplayContextProvider } from '../src/context/JamDisplayContextProvider'
import { JamWalletInfoContextProvider } from '../src/context/JamWalletInfoContextProvider'
@ -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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
@ -35,10 +37,14 @@ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)
dummy_wallet_address1='bcrt1q6rz28mcfaxtmd6v789l9rrlrusdprr9pz3cppk' # 1st address of jar A (m/84'/1'/0'/0/0)
dummy_wallet_address2='bcrt1qt5yxk3xzrx66q9wd5sdyynklqynqcyf7uh74j3' # 8th address of jar C (m/84'/1'/2'/0/7)
dummy_wallet_address3='bcrt1qn8804dw5fahuc5cwqteuq5j4xlhk2cnkq7a8kw' # 21st change address of jar E (m/84'/1'/4'/1/21)
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = 106
dummy_wallet_address_fb0='bcrt1q7war3lusq3ez633rzzfkp75unfcgrqqcu80sgp0ellhrdvkzxh5swspygk' # 1st fb address (m/84'/1'/0'/2/0)
dummy_wallet_address_fb959='bcrt1q6rlcknptmqhf4w20928tpz6px4y70960gyupd029c7vx7kdjwt0q46nk9c' # 960th fb address (m/84'/1'/0'/2/959)
# make block rewards spendable: 100 + 5 (default of `taker_utxo_age`) + 1 = at least 106 blocks
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address1" &>/dev/null
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address2" &>/dev/null
. "$script_dir/mine-block.sh" 2 "$dummy_wallet_address3" &>/dev/null
. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb0" &>/dev/null
. "$script_dir/mine-block.sh" 1 "$dummy_wallet_address_fb959" &>/dev/null
. "$script_dir/mine-block.sh" 100 &>/dev/null
start_maker() {

View file

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

View file

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

View file

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

View file

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

View file

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

4659
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import type { Jar } from '@/context/JamWalletInfoContext'
import type { WalletFileName } from '@/lib/utils'
import type { JarIndex } from '@/types/global'
import MainWalletPage from './MainWalletPage'
const navigateMock = vi.fn()
@ -32,23 +32,38 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
useJars: () => ({ jars }),
}))
vi.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
shortenStringMiddle: (s: string) => s,
walletDisplayName: (s: string) => s,
}))
vi.mock('@/components/ui/jam/ClickableJar', () => ({
ClickableJar: ({ name, onClick }: { name: string; onClick: () => void }) => <button onClick={onClick}>{name}</button>,
vi.mock('@/store/jamSettingsStore', () => ({
usePreviewFeatures: () => ({
'tx-history': true,
}),
}))
vi.mock('./wallet/WalletJarsDetailsOverlay', () => ({
WalletJarsDetailsOverlay: ({ open }: { open: boolean }) => (
<div data-testid="overlay">{open ? 'open' : 'closed'}</div>
WalletJarsDetailsOverlay: ({
open,
onOpenChange,
selectedJarIndex,
}: {
open: boolean
onOpenChange?: (open: boolean) => void
selectedJarIndex?: JarIndex
}) => (
<div data-testid="WalletJarsDetailsOverlay">
{open ? 'open' : 'closed'}
<span data-testid="WalletJarsDetailsOverlay#open">{open ? 'open' : 'closed'}</span>
<span data-testid="WalletJarsDetailsOverlay#selectedJarIndex">{selectedJarIndex ?? 'undefined'}</span>
<button data-testid="WalletJarsDetailsOverlay#onOpenChange" onClick={() => onOpenChange?.(!open)}>
Close
</button>
</div>
),
}))
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>
),
@ -58,23 +73,6 @@ vi.mock('./ui/spinner', () => ({
Spinner: () => <div data-testid="spinner" />,
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/alert', () => ({
Alert: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AlertDescription: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/components/ui/button', () => ({
Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => (
<button onClick={onClick}>{children}</button>
),
}))
const walletFileName = 'wallet.jmdat' as WalletFileName
const makeJar = (jarIndex: number, name: string): Jar =>
@ -86,6 +84,7 @@ const makeJar = (jarIndex: number, name: string): Jar =>
calculatedTotalBalanceInSats: 100,
calculatedAvailableBalanceInSats: 100,
calculatedFrozenOrLockedBalanceInSats: 0,
calculatedAvailableFrozenBalanceInSats: 0,
},
}) as unknown as Jar
@ -105,10 +104,20 @@ describe('MainWalletPage', () => {
it('renders balance and jars, and opens the jar overlay on click', () => {
render(<MainWalletPage walletFileName={walletFileName} />)
expect(screen.getByText('5000')).toBeInTheDocument()
expect(screen.getByTestId('overlay')).toHaveTextContent('closed')
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('closed')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
fireEvent.click(screen.getByText('Jar 0'))
expect(screen.getByTestId('overlay')).toHaveTextContent('open')
fireEvent.click(screen.getByText(jars[0].name))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent(String(jars[0].jarIndex))
fireEvent.click(screen.getByTestId('WalletJarsDetailsOverlay#onOpenChange'))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('closed')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
fireEvent.click(screen.getByTestId('WalletJarsDetailsOverlay#onOpenChange'))
expect(screen.getByTestId('WalletJarsDetailsOverlay#open')).toHaveTextContent('open')
expect(screen.getByTestId('WalletJarsDetailsOverlay#selectedJarIndex')).toHaveTextContent('undefined')
})
it('navigates to deposit and withdraw routes', () => {
@ -126,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()
})
@ -139,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'
@ -14,10 +14,14 @@ import {
useWalletBalanceSummary,
type Jar as JarObject,
} from '@/context/JamWalletInfoContext'
import { getErrorReason } from '@/lib/errorReason'
import type { WalletFileName } from '@/lib/utils'
import { cn, shortenStringMiddle, walletDisplayName } from '@/lib/utils'
import { usePreviewFeatures } from '@/store/jamSettingsStore'
import { Balance } from './ui/jam/Balance'
import { Spinner } from './ui/spinner'
import { TxHistoryContent } from './wallet/TxHistoryContent'
import { TxHistoryOverlay } from './wallet/TxHistoryOverlay'
import { WalletJarsDetailsOverlay } from './wallet/WalletJarsDetailsOverlay'
interface MainWalletPageProps {
@ -27,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()
@ -54,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}>
@ -67,7 +78,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
{t('global.loading')}
</div>
) : (
<div className="flex items-center text-4xl tracking-wider">
<div className="flex items-center text-3xl tracking-wider sm:text-4xl">
<Balance
onClick={() => toggleDisplayMode()}
valueString={String(walletBalanceSummary.calculatedTotalBalanceInSats)}
@ -76,7 +87,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</div>
)}
</div>
<div className="mt-2 flex w-full justify-center gap-4" data-tour-id="wallet-actions">
<div className="mt-2 flex w-full justify-center gap-2 sm:gap-4" data-tour-id="wallet-actions">
<Button size="xxl" className="flex-1" variant="default" onClick={() => void navigate(routes.receive)}>
<DownloadIcon />
{t('current_wallet.button_deposit')}
@ -86,21 +97,50 @@ 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>
{error && (
{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>
{t('global.errors.error_loading_wallet_failed', {
reason: error.message || t('global.errors.reason_unknown'),
reason: getErrorReason(error, t('global.errors.reason_unknown')),
})}
<Button variant="outline" size="sm" onClick={() => void refetchWalletData()}>
<Button variant="outline" onClick={() => void refetchWalletData()}>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
{t('global.retry')}
</Button>
</AlertDescription>
</Alert>
)}
) : null}
<div className="light:text-black mt-8 mb-4 flex w-full flex-col gap-8 text-white" data-tour-id="wallet-jars">
<div className="text-muted-foreground hover:text-foreground">
@ -115,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')}
@ -146,7 +186,7 @@ export default function MainWalletPage({ walletFileName }: MainWalletPageProps)
</div>
</div>
<div className="flex w-full max-w-xl justify-end">
<Button variant="ghost" size="xs" onClick={() => void refetchWalletData()}>
<Button variant="ghost" onClick={() => void refetchWalletData()}>
<RefreshCwIcon className={cn({ 'motion-safe:animate-spin': isFetching })} />
{t('global.refresh')}
</Button>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,30 @@
import { useState, useMemo } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import {
directsendMutation,
freezeMutation,
gettimelockaddressOptions,
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import { 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 { useForm, useWatch } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS_DELAY,
} from '@/constants/jam'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { useApiClient } from '@/hooks/useApiClient'
import type { Utxo } from '@/hooks/useQueryUtxos'
import { getErrorReason } from '@/lib/errorReason'
import * as fb from '@/lib/fidelityBondUtils'
import 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'
import {
CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES,
createFidelityBondFormSchema,
type CreateFidelityBondFormValues,
} from './CreateFidelityBondFormSchema'
import type { Step } from './types'
import { generateLockdateOptions, getYearOptions, getMonthOptions } from './types'
import { generateLockdateOptions } from './types'
export function useCreateFidelityBondWizard(
open: boolean,
@ -40,13 +40,29 @@ export function useCreateFidelityBondWizard(
const [frozenUtxos, setFrozenUtxos] = useState<Utxo[]>([])
const [utxoPage, setUtxoPage] = useState(0)
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const [error, setError] = useState<string | undefined>()
const { freezeUtxo, unfreezeUtxo, directSend, error, setError } = useFidelityBondMutations({
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.error_creating_fidelity_bond',
})
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
const existingFbLockdates = useMemo(() => {
return walletInfo.fidelityBondSummary.fbOutputs
.map((fbUtxo) => {
const locktime = fb.utxo.getLocktime(fbUtxo)
return locktime ? fb.lockdate.fromTimestamp(locktime) : null
})
.filter(Boolean) as fb.Lockdate[]
}, [walletInfo.fidelityBondSummary.fbOutputs])
const availableLockdateOptions = useMemo(
() => lockdateOptions.filter((option) => !existingFbLockdates.includes(option.value)),
[existingFbLockdates, lockdateOptions],
)
const jarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars])
const formSchema = useMemo(
() => createFidelityBondFormSchema(lockdateOptions, jarIndexes, t),
[lockdateOptions, jarIndexes, t],
() => createFidelityBondFormSchema(availableLockdateOptions, jarIndexes, t),
[availableLockdateOptions, jarIndexes, t],
)
const form = useForm<CreateFidelityBondFormValues, unknown, CreateFidelityBondFormValues>({
mode: 'onChange',
@ -55,7 +71,7 @@ export function useCreateFidelityBondWizard(
})
const watchedLockdate = useWatch({ control: form.control, name: 'lockdate' })
const selectedLockdate = watchedLockdate ?? ''
const selectedLockdate: fb.Lockdate | '' = watchedLockdate ?? ''
const selectedJarIndex = useWatch({ control: form.control, name: 'source.fromJar' })
const selectedUtxoIds = useWatch({
control: form.control,
@ -68,20 +84,6 @@ export function useCreateFidelityBondWizard(
defaultValue: CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES.confirmationAccepted,
})
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
const monthOptions = useMemo(() => getMonthOptions(), [])
const minLockdate = lockdateOptions.at(0)?.value ?? ''
const maxLockdate = lockdateOptions.at(-1)?.value ?? ''
const clampLockdate = (lockdate: string): fb.Lockdate | '' => {
if (!lockdate || lockdate < minLockdate) return minLockdate || ''
if (lockdate > maxLockdate) return maxLockdate || ''
return lockdate as fb.Lockdate
}
const selectedYear = selectedLockdate ? selectedLockdate.slice(0, 4) : ''
const selectedMonth = selectedLockdate ? selectedLockdate.slice(5, 7) : ''
const minYear = minLockdate ? Number.parseInt(minLockdate.slice(0, 4), 10) : 0
const minMonth = minLockdate ? Number.parseInt(minLockdate.slice(5, 7), 10) : 1
const setSelectedLockdate = (lockdate: fb.Lockdate | '') => {
form.setValue('lockdate', lockdate || undefined, { shouldDirty: true, shouldTouch: true, shouldValidate: true })
}
@ -99,16 +101,6 @@ export function useCreateFidelityBondWizard(
})
}
const existingFbLockdates = useMemo(() => {
return walletInfo.fidelityBondSummary.fbOutputs
.filter((fbUtxo) => fb.utxo.isLocked(fbUtxo))
.map((fbUtxo) => {
const locktime = fb.utxo.getLocktime(fbUtxo)
return locktime ? fb.lockdate.fromTimestamp(locktime) : null
})
.filter(Boolean) as fb.Lockdate[]
}, [walletInfo.fidelityBondSummary.fbOutputs])
const hasDuplicateLockdate = selectedLockdate && existingFbLockdates.includes(selectedLockdate)
const selectedJar = useMemo(() => {
@ -118,21 +110,19 @@ 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))
const availableUtxos = jar.utxos.filter((utxo) =>
fb.utxo.isEligibleForCreation(utxo, walletInfo.addressSummary[utxo.address]?.status),
)
return availableUtxos.length > 0
})
}, [walletInfo.jars])
}, [walletInfo.addressSummary, walletInfo.jars])
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))
.toSorted((a, b) => b.value - a.value)
)
}, [selectedJar])
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(() => {
const availableUtxosById = new Map<string, Utxo>(availableUtxos.map((utxo) => [utxo.utxo, utxo]))
@ -171,30 +161,6 @@ export function useCreateFidelityBondWizard(
const address = timelockAddressQuery.data?.address
const freezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_freezing_utxos')} ${reason}`)
},
})
const unfreezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_unfreezing_utxos')} ${reason}`)
},
})
const directSend = useMutation({
...directsendMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_creating_fidelity_bond')} ${reason}`)
},
})
const handleReset = () => {
setStep('select_date')
form.reset(CREATE_FIDELITY_BOND_FORM_DEFAULT_VALUES)
@ -311,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) {
@ -329,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
@ -344,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([])
@ -370,28 +356,14 @@ export function useCreateFidelityBondWizard(
}
const toggleUtxoSelection = (utxo: Utxo) => {
// TODO: select/deselect all utxos to the same address
const isSelected = selectedUtxoIds.includes(utxo.utxo)
setSelectedUtxoIds(
isSelected ? selectedUtxoIds.filter((utxoId) => utxoId !== utxo.utxo) : [...selectedUtxoIds, utxo.utxo],
)
setSelectedUtxoIds(isSelected ? [] : [utxo.utxo])
}
const selectAllUtxos = () => setSelectedUtxoIds(availableUtxos.map((utxo) => utxo.utxo))
const deselectAllUtxos = () => setSelectedUtxoIds([])
const selectedDateLabel = selectedLockdate
? new Date(fb.lockdate.toTimestamp(selectedLockdate)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: null
const canProceed = () => {
switch (step) {
case 'select_date':
return !!selectedLockdate
return !!selectedLockdate && !hasDuplicateLockdate
case 'select_jar':
return selectedJar !== undefined
case 'select_utxos':
@ -415,15 +387,8 @@ export function useCreateFidelityBondWizard(
setStep,
setSelectedLockdate,
selectedLockdate,
selectedYear,
selectedMonth,
minYear,
minMonth,
yearOptions,
monthOptions,
clampLockdate,
hasDuplicateLockdate,
selectedDateLabel,
existingFbLockdates,
selectedJarIndex,
setSelectedJarIndex,
jarsWithUtxos,
@ -432,8 +397,6 @@ export function useCreateFidelityBondWizard(
utxoPage,
setUtxoPage,
toggleUtxoSelection,
selectAllUtxos,
deselectAllUtxos,
totalAmount,
isUsingAllFunds,
utxosToFreeze,

View file

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

View file

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

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,22 +34,17 @@ const mocks = vi.hoisted(() => ({
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
walletInfo: {
addressSummary: {},
fidelityBondSummary: { fbOutputs: [] as FidelityBondUtxo[] },
hasEligibleFidelityBondUtxo: true,
isFetching: false,
isLoading: false,
jars: [] as Array<{
balanceSummary: {
calculatedAvailableBalanceInSats: number
calculatedConfirmedAvailableBalanceInSats: number
calculatedFrozenOrLockedBalanceInSats: number
calculatedTotalBalanceInSats: number
}
}>,
jars: [] as Jar[],
maxJarAvailableBalance: 100_000_000,
},
}))
vi.mock('@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query', () => ({
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
startmakerMutation: vi.fn(() => ({ mutationFn: mocks.startMaker })),
stopmakerOptions: vi.fn(() => ({ queryKey: ['stopmaker'], queryFn: vi.fn() })),
}))
@ -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),
}),
@ -117,14 +131,6 @@ vi.mock('@/components/ui/jam/PageLoading', () => ({
PageLoading: () => <div>page-loading</div>,
}))
vi.mock('@/components/ui/jam/PageTitle', () => ({
default: ({ title, subtitle }: { title: string; subtitle: string }) => (
<h1>
{title}:{subtitle}
</h1>
),
}))
vi.mock('@/context/JamWalletInfoContext', () => ({
useJamWalletInfoContext: () => mocks.walletInfo,
}))
@ -174,6 +180,7 @@ vi.mock('./EarnForm', () => ({
<div>
earn-form:{String(disabled)}:{String(debug)}
<button
disabled={disabled}
onClick={() =>
void onSubmit({
offerMinAmount: 50_000,
@ -202,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>
),
@ -220,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({
@ -256,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({})
@ -269,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()
})
@ -283,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: {
@ -312,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({
@ -321,78 +395,275 @@ 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
render(<EarnPage walletFileName="wallet.jmdat" />)
expect(screen.getByText('Waiting for maker to start...')).toBeInTheDocument()
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('Waiting for maker to stop...')).toBeInTheDocument()
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()
})
@ -400,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

@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { startmakerMutation, stopmakerOptions } from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { ErrorMessage, StartMakerRequest } from '@joinmarket-webui/joinmarket-api-ts/jm'
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, 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'
@ -16,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'
@ -30,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
@ -67,37 +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">
Fund your wallet on the
<Link to={routes.receive} className="font-semibold">
Receive page
</Link>
to start earning.
</Trans>
</AlertDescription>
</Alert>
)
}
const JAM_EARN_CREATE_MULTIPLE_FIDELITY_BONDS_ENABLED = isDevMode()
export const EarnPage = ({ walletFileName }: EarnPageProps) => {
const { t } = useTranslation()
@ -111,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>()
@ -126,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,
@ -135,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,
@ -150,10 +136,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
onMutate: () => {
toast.info(t('earn.alert_stopping'), { id: 'earn.alert_stopping' })
},
onError: (error: ErrorMessage) => {
onError: (error: unknown) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
// TODO: i18n
toast.error(`Error while stopping the earn process. Reason: ${reason}`)
toast.error(t('earn.error_stopping_earn', { reason }))
},
})
@ -163,7 +148,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
onMutate: () => {
toast.info(t('earn.alert_starting'), { id: 'earn.alert_starting' })
},
onError: (error: ErrorMessage) => {
onError: (error) => {
console.error('StartMaker error:', error)
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
toast.error(t('earn.alert_start_failed', { reason }))
@ -176,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 () => {
@ -222,13 +235,6 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
<div className="mx-auto max-w-4xl space-y-3 p-4">
<PageTitle title={t('earn.title')} subtitle={t('earn.subtitle')} />
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => setShowEarnReport(true)}>
<FileTextIcon />
{t('earn.button_show_report')}
</Button>
</div>
{feeConfigValidation.maxFeesConfigMissing && (
<FeeConfigErrorAlert onOpenFeeConfig={() => setShowFeeConfigDialog(true)} className="mb-4" />
)}
@ -236,7 +242,7 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{jmSession.coinjoin_in_process === true && (
<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>
)}
@ -249,13 +255,13 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
{isWaitingMakerStart && (
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{/* TODO: i18n */ t('Waiting for maker to start...')}</AlertTitle>
<AlertTitle>{t('earn.alert_waiting_start_title')}</AlertTitle>
</Alert>
)}
{isWaitingMakerStop && (
<Alert variant="default" className="motion-safe:animate-in blur-in my-2">
<Spinner className="motion-reduce:hidden" />
<AlertTitle>{/* TODO: i18n */ t('Waiting for maker to stop...')}</AlertTitle>
<AlertTitle>{t('earn.alert_waiting_stop_title')}</AlertTitle>
</Alert>
)}
{waitingForOfferUpdate && (
@ -265,11 +271,33 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</Alert>
)}
{!isWaitingMakerStart && !isWaitingMakerStop && !jmSession.maker_running && earnPreconditionWarning && (
<>
{earnPreconditionWarning === 'non-frozen-fb-present' ? (
<NonFrozenFidelityBondsAlert numberOfNonFrozenFidelityBondOutputs={numberOfNonFrozenFidelityBondOutputs} />
) : null}
{earnPreconditionWarning === 'missing-utxos' ? (
<UnconfirmedBalanceAlert numberOfMissingConfirmations={1} />
) : null}
{earnPreconditionWarning === 'missing-confirmations' ? <NoSpendableBalanceAlert /> : null}
</>
)}
<div className="flex justify-end">
<Button variant="outline" onClick={() => setShowEarnReport(true)}>
<FileTextIcon />
{t('earn.button_show_report')}
</Button>
</div>
{jmSession.offer_list && jmSession.offer_list.length > 0 && (
<OfferCard
className="motion-safe:animate-in blur-in"
value={jmSession.offer_list[0]}
nickname={jmSession.nickname}
orderbookStatus={orderbookStatus}
orderbookOffer={currentOrderbookOffer}
fidelityBond={currentOrderbookFidelityBond}
>
<Button type="button" onClick={() => void onStop()} className="w-full" size="lg">
{isWaitingMakerStop ? (
@ -288,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 ||
@ -316,9 +346,9 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</Card>
{/* Fidelity Bonds */}
{walletInfo.fidelityBondSummary.fbOutputs.length === 0 ? (
{!hasFidelityBond ? (
<div
className={cn({
className={cn('mt-8', {
hidden: jmSession.maker_running || waitingForMakerUpdate || waitingForOfferUpdate,
})}
>
@ -331,39 +361,64 @@ 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>
</Card>
</div>
) : (
<>
<div className="mt-8">
{/* Fidelity Bonds exist */}
<h2 className="my-2 text-xl font-semibold tracking-tight">
{t('earn.title_fidelity_bonds', { count: walletInfo.fidelityBondSummary.fbOutputs.length })}
</h2>
<p className="text-muted-foreground mb-4 text-sm">{t('earn.subtitle_fidelity_bonds')}</p>
<div className="flex flex-col gap-2">
<div className="space-y-4">
{walletInfo.fidelityBondSummary.fbOutputs.map((it) => {
const isExpired = !fb.utxo.isLocked(it)
const actionsEnabled =
isExpired &&
!jmSession.rescanning &&
!jmSession.maker_running &&
!waitingForMakerUpdate &&
!waitingForOfferUpdate &&
!walletInfo.isFetching
const actionsEnabled = isExpired && isFidelityBondActionsEnabled
return (
<FidelityBondCard value={it} key={it.utxo}>
{actionsEnabled && (
<div className="flex w-full flex-row gap-2">
<Button variant="secondary" className="flex-1" onClick={() => setMoveToJarUtxo(it)}>
{isExpired && (
<div className="flex w-full flex-col gap-2 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
onClick={() => setMoveToJarUtxo(it)}
disabled={!actionsEnabled}
>
<UnlockIcon />
{t('earn.fidelity_bond.existing.button_spend')}
</Button>
<Button variant="default" className="flex-1" onClick={() => setRenewBondUtxo(it)}>
<Button
variant="default"
className="flex-1"
onClick={() => setRenewBondUtxo(it)}
disabled={!actionsEnabled}
>
<RefreshCwIcon />
{t('earn.fidelity_bond.existing.button_renew')}
</Button>
@ -373,7 +428,21 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
)
})}
</div>
</>
{showCreateAdditionalFidelityBond && (
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
disabled={!isCreateFidelityBondEnabled}
onClick={() => setShowCreateFidelityBondDialog(true)}
>
<PlusIcon />
{t('earn.fidelity_bond.create_form.button_create')}
</Button>
<DevBadge />
</div>
)}
</div>
)}
{/* Fee Configuration Dialog */}
@ -418,11 +487,11 @@ export const EarnPage = ({ walletFileName }: EarnPageProps) => {
</CardHeader>
<CardContent className="flex flex-col gap-2">
<div className="overflow-scroll">
<code className="light:text-red-700 text-red-800">walletInfo.fidelityBondSummary.fbOutputs:</code>
<code className="text-destructive">walletInfo.fidelityBondSummary.fbOutputs:</code>
<pre className="text-xs">{JSON.stringify(walletInfo.fidelityBondSummary.fbOutputs, null, 2)}</pre>
</div>
<div className="mt-8 overflow-scroll">
<code className="light:text-red-700 text-red-800">jmSessionState.offer_list:</code>
<code className="text-destructive">jmSessionState.offer_list:</code>
<pre className="text-xs">{JSON.stringify(jmSession.offer_list, null, 2)}</pre>
</div>
</CardContent>

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 className="w-full">
<Card className={cn('transition-all duration-300 hover:-translate-y-[2px] hover:shadow-md', className)}>
<CardHeader>
<CardTitle>
{isExpired ? (
@ -42,7 +43,7 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
t('earn.fidelity_bond.existing.title_active')
)}
</CardTitle>
<CardDescription className="text-xs">
<CardDescription className="min-w-0 text-xs break-all">
<Tooltip>
<TooltipTrigger asChild>
<code>{value.path}</code>
@ -54,29 +55,29 @@ export function FidelityBondCard({ value, children }: PropsWithChildren<Fidelity
</Tooltip>
</CardDescription>
<CardAction>
<div className="flex items-center gap-2">
<CoinsIcon />
<div>
<div className="flex min-w-0 items-center gap-2">
<CoinsIcon className="shrink-0" />
<div className="min-w-0">
<Balance valueString={String(value.value)} />
</div>
</div>
</CardAction>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<ClockIcon />
<div className="flex flex-col">
<div className="flex items-start gap-4">
<ClockIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-muted-foreground text-sm font-semibold">
{t(`earn.fidelity_bond.existing.${isExpired ? 'label_expired_on' : 'label_locked_until'}`)}
</span>
<span className="text-md font-mono">
<span className="text-md block font-mono break-words">
{value.locktime} ({humanReadableLockDuration})
</span>
</div>
</div>
<div className="flex items-center gap-4">
<CopyIcon />
<div className="flex flex-col">
<div className="flex items-start gap-4">
<CopyIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-muted-foreground text-sm font-semibold">
{t('earn.fidelity_bond.existing.label_address')}
</span>

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-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
getaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
@ -64,10 +70,29 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
utxos: [],
},
],
walletBalanceSummary: { calculatedTotalBalanceInSats: 6000 },
refetch: h.refetch,
}),
}))
vi.mock('@/components/ui/jam/SelectableJar', () => ({
SelectableJar: ({
name,
onSelect,
disabled,
isSelected,
}: {
name?: string
onSelect?: () => void
disabled?: boolean
isSelected?: boolean
}) => (
<button onClick={onSelect} disabled={disabled} data-selected={isSelected}>
{name}
</button>
),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ children, open, onOpenChange }: DialogProps) =>
open ? (
@ -93,6 +118,10 @@ vi.mock('@/components/ui/jam/Address', () => ({
Address: ({ value }: ValueProps) => <div data-testid="address">{value}</div>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
}))
vi.mock('@/components/ui/jam/CopyButton', () => ({
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
}))
@ -118,13 +147,14 @@ const renderDialog = (props?: { utxo?: FidelityBondUtxo; onOpenChange?: (open: b
const goToConfirm = () => {
fireEvent.click(screen.getByText('Jar 1'))
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
fireEvent.click(screen.getByText('global.next'))
}
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 = []
@ -134,35 +164,35 @@ describe('MoveToJarDialog', () => {
renderDialog()
expect(screen.getByText('earn.fidelity_bond.move.title')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.move.select_jar.description')).toBeInTheDocument()
expect(screen.getByText('Jar 0')).toBeEnabled()
expect(screen.getByText('Jar 1')).toBeInTheDocument()
})
it('moves to the confirm step and shows the destination address', () => {
renderDialog()
goToConfirm()
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_jar_n {"jar":1}')).toBeInTheDocument()
expect(screen.getByText('Jar 1')).toBeInTheDocument()
expect(screen.getByTestId('address')).toHaveTextContent('bc111')
})
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('earn.fidelity_bond.move.error_loading_address')).toBeInTheDocument()
expect(screen.getByText('global.errors.error_loading_address_failed')).toBeInTheDocument()
})
it('completes the move flow and reaches the success step', async () => {
h.extraUtxos = [{ utxo: 'tx2:0', value: 500, frozen: false }]
renderDialog()
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
@ -174,19 +204,17 @@ describe('MoveToJarDialog', () => {
const frozenUtxo = { ...utxo, frozen: true } as unknown as FidelityBondUtxo
renderDialog({ utxo: frozenUtxo })
goToConfirm()
fireEvent.click(screen.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() => expect(screen.getByText('earn.fidelity_bond.move.success_text')).toBeInTheDocument())
const calls = h.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.getByTestId('confirm-switch'))
fireEvent.click(screen.getByText('earn.fidelity_bond.move.text_button_submit'))
await waitFor(() =>

View file

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

View file

@ -1,6 +1,7 @@
import type { SessionResponse } from '@joinmarket-webui/joinmarket-api-ts/jm'
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>,
}))
@ -64,7 +65,7 @@ describe('OfferCard', () => {
})
it('renders without txfee', () => {
const offer = { ...baseOffer, ordertype: 'sw0abso', txfee: undefined }
const offer = { ...baseOffer, ordertype: 'sw0abso', txfee: undefined } as unknown as Offer
render(<OfferCard value={offer} nickname="JMBot" />)
expect(screen.queryByText('earn.current.text_txfee')).not.toBeInTheDocument()
@ -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-api-ts/jm'
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'
@ -15,33 +26,71 @@ type Offer = NonNullable<SessionResponse['offer_list']>[number]
const OfferTypeBadge = ({ value }: { value: Offer }) => {
const { t } = useTranslation()
const text = renderOfferText(value, t)
return <Badge variant={text ? 'default' : 'outline'}>{text}</Badge>
return (
<Badge className="max-w-full truncate" variant={text ? 'default' : 'outline'}>
{text}
</Badge>
)
}
const renderOfferText = (value: Offer, t: TFunction<'translation', undefined>) => {
if (isAbsoluteOffer(value?.ordertype || '')) {
if (isAbsoluteOffer(String(value?.ordertype || ''))) {
return t('earn.current.text_offer_type_absolute')
}
if (isRelativeOffer(value?.ordertype || '')) {
if (isRelativeOffer(String(value?.ordertype || ''))) {
return t('earn.current.text_offer_type_relative')
}
return value?.ordertype
return String(value?.ordertype || '')
}
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>
@ -52,41 +101,43 @@ export function OfferCard({ className, value, nickname, children }: PropsWithChi
</CardAction>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="flex items-center gap-4">
<FingerprintIcon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<FingerprintIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_offer_id')}</Label>
<span className="text-md font-mono select-all">
<span className="text-md block font-mono break-all select-all">
{nickname}:{value?.oid}
</span>
</div>
</div>
<div className="flex items-center gap-4">
<HandCoinsIcon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<HandCoinsIcon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_cjfee')}</Label>
<span className="text-sm">
{isRelativeOffer(value?.ordertype || '') ? (
<span className="select-all">{factorToPercentage(Number.parseFloat(value?.cjfee || '') || 0)}%</span>
{isRelativeOffer(String(value?.ordertype || '')) ? (
<span className="select-all">
{factorToPercentage(Number.parseFloat(String(value?.cjfee || '')) || 0)}%
</span>
) : (
<Balance valueString={String(value?.cjfee || '0')} />
)}
</span>
</div>
</div>
<div className="flex items-center gap-4">
<Minimize2Icon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<Minimize2Icon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_minsize')}</Label>
<span className="text-sm">
<Balance valueString={String(value?.minsize || '0')} />
</span>
</div>
</div>
<div className="flex items-center gap-4">
<Maximize2Icon />
<div className="flex flex-col">
<div className="flex min-w-0 items-start gap-4">
<Maximize2Icon className="mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<Label className="font-semibold">{t('earn.current.text_maxsize')}</Label>
<span className="text-sm">
<Balance valueString={String(value?.maxsize || '0')} />
@ -94,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')} />
@ -103,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-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({})),
freezeMutation: vi.fn(() => ({})),
vi.mock('@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query', () => ({
directsendMutation: vi.fn(() => ({ mutationFn: h.directSendAsync })),
freezeMutation: vi.fn(() => ({ mutationFn: h.freezeAsync })),
gettimelockaddressOptions: vi.fn(() => ({ queryKey: ['mock'], queryFn: vi.fn() })),
}))
@ -56,7 +61,7 @@ vi.mock('@/context/JamWalletInfoContext', () => ({
vi.mock('@/lib/errorReason', () => ({ getErrorReason: () => 'reason' }))
vi.mock('@/lib/fidelityBondUtils', () => ({
lockdate: { toTimestamp: () => 1_893_456_000_000 },
lockdate: { toDateLabel: () => 'January 1, 2030' },
}))
vi.mock('@/lib/utils', async (importOriginal) => ({
@ -136,6 +141,10 @@ vi.mock('@/components/ui/jam/CopyButton', () => ({
CopyButton: ({ value }: ValueProps) => <div data-testid="copy">{value}</div>,
}))
vi.mock('@/components/ui/jam/Balance', () => ({
Balance: ({ valueString }: { valueString: string }) => <span>{valueString} sats</span>,
}))
vi.mock('@/components/ui/label', () => ({ Label: ({ children }: ChildrenProps) => <label>{children}</label> }))
vi.mock('../ui/jam/Address', () => ({
@ -166,15 +175,15 @@ const selectYear = (value: string) => {
const goToConfirm = () => {
selectMonth('06')
fireEvent.click(screen.getByText('earn.fidelity_bond.select_date.text_primary_button'))
fireEvent.click(screen.getByText('global.next'))
}
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 = []
@ -189,13 +198,13 @@ describe('RenewBondDialog', () => {
it('selecting a month enables the next button and shows the chosen date', () => {
renderDialog()
selectMonth('06')
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
})
it('selecting a year computes a clamped lock date', () => {
renderDialog()
selectYear('2027')
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
})
it('selecting an early month bumps the year forward', () => {
@ -203,7 +212,7 @@ describe('RenewBondDialog', () => {
// a month earlier than the minimum month forces year = minYear + 1
selectMonth('06')
selectYear('2026')
expect(screen.getByText('earn.fidelity_bond.review_inputs.label_lock_date')).toBeInTheDocument()
expect(screen.getByText('earn.fidelity_bond.select_date.label_selected_lock_date')).toBeInTheDocument()
})
it('moves to the confirm step and shows the timelock address', () => {
@ -213,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()
})
@ -245,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,50 +1,41 @@
import { useState, useMemo } from 'react'
import {
directsendMutation,
freezeMutation,
gettimelockaddressOptions,
} from '@joinmarket-webui/joinmarket-api-ts/@tanstack/react-query'
import type { DirectSendResponse, ErrorMessage } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation, useQuery } from '@tanstack/react-query'
import {
AlertTriangleIcon,
CalendarIcon,
CheckCircle2Icon,
CheckIcon,
ChevronLeftIcon,
CopyIcon,
RefreshCwIcon,
} from 'lucide-react'
import { 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 { buttonVariants } from '@/components/ui/button-variants'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { CopyButton } from '@/components/ui/jam/CopyButton'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext'
import { JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS } from '@/constants/jam'
import { useApiClient } from '@/hooks/useApiClient'
import type { FidelityBondUtxo, Utxo } from '@/hooks/useQueryUtxos'
import { getErrorReason } from '@/lib/errorReason'
import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos'
import * as fb from '@/lib/fidelityBondUtils'
import { cn, formatSats, type WalletFileName } from '@/lib/utils'
import { useDeveloperMode } from '@/store/jamSettingsStore'
import { Address } from '../ui/jam/Address'
import { generateLockdateOptions, getYearOptions, getMonthOptions } from './CreateFidelityBondDialog/types'
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,
ConfirmationToggle,
CopyableField,
FidelityBondAmount,
InfoCard,
InlineLoading,
JarBadge,
PendingStep,
StepIntro,
SuccessHeading,
} from './fidelity-bond/FidelityBondDialogParts'
import { LockdateSelect } from './fidelity-bond/LockdateSelect'
import { WizardStepFooter } from './fidelity-bond/WizardStepFooter'
import { useFidelityBondSweep } from './fidelity-bond/useFidelityBondSweep'
type Step = 'select_date' | 'confirm' | 'sending' | 'success'
const WIZARD_STEPS: Step[] = ['select_date', 'confirm']
interface RenewBondDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@ -55,46 +46,35 @@ interface RenewBondDialogProps {
export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: RenewBondDialogProps) {
const { t } = useTranslation()
const client = useApiClient()
const walletInfo = useJamWalletInfoContext()
const { enabled: isDeveloperMode } = useDeveloperMode()
const [step, setStep] = useState<Step>('select_date')
const [selectedLockdate, setSelectedLockdate] = useState<fb.Lockdate | ''>('')
const [confirmationChecked, setConfirmationChecked] = useState(false)
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const [error, setError] = useState<string | undefined>()
const lockdateOptions = useMemo(() => generateLockdateOptions(isDeveloperMode), [isDeveloperMode])
const yearOptions = useMemo(() => getYearOptions(lockdateOptions), [lockdateOptions])
const monthOptions = useMemo(() => getMonthOptions(), [])
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 minLockdate = lockdateOptions.at(0)?.value ?? ''
const maxLockdate = lockdateOptions.at(-1)?.value ?? ''
const clampLockdate = (lockdate: string): fb.Lockdate | '' => {
if (!lockdate || lockdate < minLockdate) return minLockdate || ''
if (lockdate > maxLockdate) return maxLockdate || ''
return lockdate as fb.Lockdate
}
const selectedYear = selectedLockdate ? selectedLockdate.slice(0, 4) : ''
const selectedMonth = selectedLockdate ? selectedLockdate.slice(5, 7) : ''
const minYear = minLockdate ? Number.parseInt(minLockdate.slice(0, 4), 10) : 0
const minMonth = minLockdate ? Number.parseInt(minLockdate.slice(5, 7), 10) : 1
const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({
walletFileName,
utxo,
unfreezeErrorKey: 'earn.fidelity_bond.error_unfreezing_utxos',
sendErrorKey: 'earn.fidelity_bond.renew.error_renewing_fidelity_bond',
})
const selectedDateLabel = selectedLockdate
? new Date(fb.lockdate.toTimestamp(selectedLockdate)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: null
const sourceJar = walletInfo.jars.find((jar) => jar.jarIndex === utxo.mixdepth)
// UTXOs in the source jar that are NOT this FB — they need to be frozen during sweep
const utxosToFreeze = useMemo(() => {
if (!sourceJar) return []
return sourceJar.utxos.filter((u) => u.utxo !== utxo.utxo && !u.frozen)
}, [sourceJar, utxo.utxo])
const selectedDateLabel = selectedLockdate ? fb.lockdate.toDateLabel(selectedLockdate) : null
const timelockAddressQuery = useQuery({
...gettimelockaddressOptions({
@ -109,40 +89,13 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
retry: false,
})
if (timelockAddressQuery.isError && !error) {
setError(t('earn.fidelity_bond.error_loading_address'))
}
const destinationAddress = timelockAddressQuery.data?.address
const freezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_freezing_utxos')} ${reason}`)
},
})
const unfreezeUtxo = useMutation({
...freezeMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.error_unfreezing_utxos')} ${reason}`)
},
})
const directSend = useMutation({
...directsendMutation({ client }),
onError: (error: ErrorMessage) => {
const reason = getErrorReason(error, t('global.errors.reason_unknown'))
setError(`${t('earn.fidelity_bond.renew.error_renewing_fidelity_bond')} ${reason}`)
},
})
const displayError =
error ?? (timelockAddressQuery.isError ? t('earn.fidelity_bond.error_loading_address') : undefined)
const handleReset = () => {
setStep('select_date')
setSelectedLockdate('')
setConfirmationChecked(false)
reset(RENEW_BOND_FORM_DEFAULT_VALUES)
setTxResult(undefined)
setError(undefined)
}
@ -154,348 +107,161 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
onOpenChange(newOpen)
}
const handleSubmit = async () => {
const submitRenewal = handleSubmit(async () => {
if (!destinationAddress) return
setStep('sending')
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 txResult = await sweep({
destination: destinationAddress,
tryFreezeAfterBroadcast: JAM_TRY_FREEZE_CREATED_FIDELITY_BOND_OUTPUTS,
})
if (utxo.frozen) {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': utxo.utxo, freeze: false },
})
}
const result = await directSend.mutateAsync({
path: { walletname: walletFileName },
body: {
mixdepth: utxo.mixdepth,
amount_sats: 0,
destination: destinationAddress,
},
})
setTxResult(result)
setTxResult(txResult)
if (txResult) {
setStep('success')
toast.success(t('earn.fidelity_bond.renew.success_text'))
// Best-effort cleanup — tx already broadcast, don't throw on unfreeze failure
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
})
} catch {
// logged via onError
}
}
await walletInfo.refetch()
} catch {
// Best-effort rollback — unfreeze UTXOs that were frozen before the error
for (const u of frozen) {
try {
await unfreezeUtxo.mutateAsync({
path: { walletname: walletFileName },
body: { 'utxo-string': u.utxo, freeze: false },
})
} catch {
// logged via onError
}
}
} else {
setStep('confirm')
}
})
const renderFooter = () => {
switch (step) {
case 'select_date':
return (
<WizardStepFooter
onCancel={() => handleOpenChange(false)}
onPrimary={() => setStep('confirm')}
primaryDisabled={!selectedLockdate}
primaryLabel={t('global.next')}
/>
)
case 'confirm':
return (
<WizardStepFooter
onBack={() => setStep('select_date')}
onCancel={() => handleOpenChange(false)}
onPrimary={() => void submitRenewal()}
primaryDisabled={!confirmationChecked || !destinationAddress}
isLoading={isLoading || isSubmitting}
primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')}
/>
)
case 'sending':
return null
case 'success':
return <Button onClick={() => handleOpenChange(false)}>{t('global.done')}</Button>
}
}
const isLoading = freezeUtxo.isPending || unfreezeUtxo.isPending || directSend.isPending
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">{t('earn.fidelity_bond.renew.title')}</DialogTitle>
<DialogDescription>{t('earn.fidelity_bond.subtitle')}</DialogDescription>
</DialogHeader>
<FidelityBondDialogLayout
open={open}
onOpenChange={handleOpenChange}
title={t('earn.fidelity_bond.renew.title')}
currentStep={step === 'sending' || step === 'success' ? undefined : WIZARD_STEPS.indexOf(step)}
totalSteps={WIZARD_STEPS.length}
error={displayError}
footer={renderFooter()}
>
{step === 'select_date' && (
<div className="space-y-6">
<StepIntro icon={CalendarIcon} title={t('earn.fidelity_bond.select_date.description')} />
{error && (
<Alert variant="destructive" className="animate-in fade-in-50">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_amount')}>
<FidelityBondAmount value={utxo.value} className="text-lg" />
</InfoCard>
<LockdateSelect
id="renew-lockdate"
value={selectedLockdate ?? ''}
onChange={(lockdate) =>
setValue('lockdate', lockdate || undefined, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
</div>
)}
{step === 'confirm' && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_lock_date')}>
<p className="font-semibold">{selectedDateLabel}</p>
</InfoCard>
<InfoCard label={t('earn.fidelity_bond.review_inputs.label_jar')}>
<JarBadge jarIndex={utxo.mixdepth} name={sourceJar?.name} />
</InfoCard>
</div>
<InfoCard highlight label={t('earn.fidelity_bond.review_inputs.label_amount')}>
<FidelityBondAmount value={utxo.value} />
</InfoCard>
{timelockAddressQuery.isLoading ? (
<InlineLoading text={t('earn.fidelity_bond.renew.text_loading')} />
) : (
destinationAddress && (
<AddressPreview
label={t('earn.fidelity_bond.review_inputs.label_address')}
address={destinationAddress}
/>
)
)}
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.fidelity_bond.renew.confirm_send_modal.title')}</AlertTitle>
<AlertDescription>
{t('earn.fidelity_bond.confirm_modal.body', {
/* TODO: fix human readable duration */
humanReadableDuration: selectedDateLabel ? `until ${selectedDateLabel}` : '',
date: selectedDateLabel || '',
})}
</AlertDescription>
</Alert>
)}
<div className="py-2">
{step === 'select_date' && (
<div className="space-y-6">
<div className="bg-muted/50 flex items-center gap-3 rounded-lg p-4">
<div className="bg-primary/10 rounded-lg p-2">
<CalendarIcon className="text-primary h-5 w-5" />
</div>
<div className="flex-1">
<p className="font-medium">{t('earn.fidelity_bond.select_date.description')}</p>
</div>
</div>
<ConfirmationToggle
id="renew-confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) =>
setValue('confirmationAccepted', checked, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
</div>
)}
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-1 text-xs">
{t('earn.fidelity_bond.review_inputs.label_amount')}
</p>
<p className="font-mono text-lg font-bold">{formatSats(utxo.value)}</p>
</div>
{step === 'sending' && <PendingStep icon={RefreshCwIcon} title={t('earn.fidelity_bond.renew.text_sending')} />}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="renew-lockdate-month" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_month')}
</Label>
<Select
value={selectedMonth}
onValueChange={(month) => {
const year =
selectedYear || String(Number.parseInt(month, 10) >= minMonth ? minYear : minYear + 1)
const newLockdate = clampLockdate(`${year}-${month}`)
setSelectedLockdate(newLockdate)
}}
>
<SelectTrigger id="renew-lockdate-month" className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_month')} />
</SelectTrigger>
<SelectContent>
{monthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="renew-lockdate-year" className="text-sm font-medium">
{t('earn.fidelity_bond.select_date.form_label_year')}
</Label>
<Select
value={selectedYear}
onValueChange={(year) => {
const month = selectedMonth || String(year === String(minYear) ? minMonth : 1).padStart(2, '0')
const newLockdate = clampLockdate(`${year}-${month}`)
setSelectedLockdate(newLockdate)
}}
>
<SelectTrigger id="renew-lockdate-year" className="h-11 w-full">
<SelectValue placeholder={t('earn.fidelity_bond.select_date.form_label_year')} />
</SelectTrigger>
<SelectContent>
{yearOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{step === 'success' && (
<div className="space-y-6">
<SuccessHeading title={t('earn.fidelity_bond.renew.success_text')} />
{selectedLockdate && (
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
<p className="text-muted-foreground text-sm">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
</p>
<p className="mt-1 text-lg font-semibold">{selectedDateLabel}</p>
</div>
)}
</div>
{destinationAddress && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_address')}
value={destinationAddress}
copiedMessage={t('receive.text_copy_address')}
/>
)}
{step === 'confirm' && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.review_inputs.label_lock_date')}
</p>
<p className="font-semibold">{selectedDateLabel}</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">{t('earn.fidelity_bond.review_inputs.label_jar')}</p>
<p className="font-semibold">
{t('earn.fidelity_bond.review_inputs.label_jar_n', { jar: utxo.mixdepth })}
</p>
</div>
</div>
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
<p className="text-muted-foreground text-xs">{t('earn.fidelity_bond.review_inputs.label_amount')}</p>
<p className="font-mono text-2xl font-bold">{formatSats(utxo.value)}</p>
</div>
{timelockAddressQuery.isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-4">
<Spinner className="motion-reduce:hidden" />
{t('earn.fidelity_bond.renew.text_loading')}
</div>
) : (
destinationAddress && (
<div className="space-y-2">
<Label className="text-sm font-medium">{t('earn.fidelity_bond.review_inputs.label_address')}</Label>
<div className="bg-muted rounded-lg p-3">
<Address className="text-xs" value={destinationAddress} />
</div>
</div>
)
)}
<Alert variant="warning">
<AlertTriangleIcon />
<AlertTitle>{t('earn.fidelity_bond.renew.confirm_send_modal.title')}</AlertTitle>
<AlertDescription>
{t('earn.fidelity_bond.confirm_modal.body', {
/* TODO: fix human readable duration */
humanReadableDuration: selectedDateLabel ? `until ${selectedDateLabel}` : '',
date: selectedDateLabel || '',
})}
</AlertDescription>
</Alert>
<div className="bg-muted/50 flex items-start gap-3 rounded-lg p-4">
<Switch
id="renew-confirmation"
checked={confirmationChecked}
onCheckedChange={(checked) => setConfirmationChecked(checked)}
/>
<div className="grid gap-1.5 leading-none">
<Label htmlFor="renew-confirmation" className="cursor-pointer text-sm font-medium">
{t('earn.fidelity_bond.create_form.confirmation_toggle_title')}
</Label>
<p className="text-muted-foreground text-xs">
{t('earn.fidelity_bond.create_form.confirmation_toggle_subtitle')}
</p>
</div>
</div>
</div>
)}
{step === 'sending' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="relative">
<Spinner className="h-16 w-16" />
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCwIcon className="text-primary h-6 w-6 animate-pulse" />
</div>
</div>
<p className="mt-6 text-lg font-semibold">{t('earn.fidelity_bond.renew.text_sending')}</p>
</div>
)}
{step === 'success' && (
<div className="space-y-6">
<div className="flex flex-col items-center py-6">
<div className="rounded-full bg-green-500/10 p-4">
<CheckCircle2Icon className="h-16 w-16 text-green-500" />
</div>
<p className="mt-4 text-xl font-bold">{t('earn.fidelity_bond.renew.success_text')}</p>
</div>
{destinationAddress && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_address')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
{destinationAddress}
</code>
<CopyButton
key="copy-address-renew"
value={destinationAddress}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() => toast.success(t('receive.text_copy_address'))}
onError={() => toast.error(t('global.errors.reason_unknown'))}
/>
</div>
</div>
)}
{txResult?.txinfo?.txid && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
</Label>
<div className="flex items-center gap-2">
<code className="bg-muted flex-1 rounded-lg p-3 font-mono text-xs break-all">
{txResult.txinfo.txid}
</code>
<CopyButton
key="copy-txid-renew"
value={txResult.txinfo.txid}
text={<CopyIcon className="h-4 w-4" />}
successText={<CheckIcon className="h-4 w-4 text-green-500" />}
className={cn(buttonVariants({ variant: 'outline', size: 'icon' }), 'h-10 w-10 shrink-0')}
onSuccess={() =>
toast.success(t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id'))
}
onError={() => toast.error(t('global.errors.reason_unknown'))}
/>
</div>
</div>
)}
</div>
{txResult?.txinfo?.txid && (
<CopyableField
label={t('earn.fidelity_bond.create_fidelity_bond.label_transaction_id')}
value={txResult.txinfo.txid}
copiedMessage={t('earn.fidelity_bond.create_fidelity_bond.text_copy_transaction_id')}
/>
)}
</div>
{step === 'select_date' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.select_date.text_secondary_button')}
</Button>
<Button className="min-w-32" disabled={!selectedLockdate} onClick={() => setStep('confirm')}>
{t('earn.fidelity_bond.select_date.text_primary_button')}
</Button>
</DialogFooter>
)}
{step === 'confirm' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button variant="ghost" onClick={() => setStep('select_date')} disabled={isLoading}>
<ChevronLeftIcon className="mr-1 h-4 w-4" />
{t('global.back')}
</Button>
<Button variant="outline" className="min-w-24" onClick={() => handleOpenChange(false)} disabled={isLoading}>
{t('earn.fidelity_bond.select_date.text_secondary_button')}
</Button>
<Button
className="min-w-32"
disabled={!confirmationChecked || !destinationAddress || isLoading}
onClick={() => void handleSubmit()}
>
{isLoading && <Spinner className="mr-2 h-4 w-4" />}
{t('earn.fidelity_bond.renew.text_button_submit')}
</Button>
</DialogFooter>
)}
{step === 'success' && (
<DialogFooter className="gap-3 sm:gap-2">
<Button className="min-w-32" onClick={() => handleOpenChange(false)}>
{t('earn.fidelity_bond.create_fidelity_bond.text_primary_button')}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)}
</FidelityBondDialogLayout>
)
}

View file

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

View file

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

View file

@ -0,0 +1,33 @@
import { SelectableJar } from '@/components/ui/jam/SelectableJar'
import { useJamWalletInfoContext, type Jar } from '@/context/JamWalletInfoContext'
import type { JarIndex } from '@/types/global'
type FidelityBondJarSelectorProps = {
selectedJarIndex: JarIndex | undefined
onSelect: (jarIndex: JarIndex) => void
isJarDisabled: (jar: Jar) => boolean
}
/** jar selection grid shared by the fidelity bond flows, same look as the send flow's jar selector */
export function FidelityBondJarSelector({ selectedJarIndex, onSelect, isJarDisabled }: FidelityBondJarSelectorProps) {
const { jars, walletBalanceSummary } = useJamWalletInfoContext()
return (
<div className="flex flex-1 flex-row flex-wrap items-center justify-center gap-2 gap-y-4">
{jars.map((jar) => (
<SelectableJar
key={jar.jarIndex}
name={jar.name}
color={jar.color}
totalBalance={jar.balanceSummary.calculatedTotalBalanceInSats}
availableBalance={jar.balanceSummary.calculatedAvailableBalanceInSats}
frozenOrLockedBalance={jar.balanceSummary.calculatedFrozenOrLockedBalanceInSats}
totalWalletBalance={walletBalanceSummary.calculatedTotalBalanceInSats}
isSelected={selectedJarIndex === jar.jarIndex}
onSelect={() => onSelect(jar.jarIndex)}
disabled={isJarDisabled(jar)}
/>
))}
</div>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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

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

View file

@ -118,24 +118,49 @@ 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 () => {
const anchorClick = vi.fn()
const createElement = vi.spyOn(document, 'createElement')
createElement.mockImplementation((tagName: string) => {
// eslint-disable-next-line unicorn/prefer-https -- XHTML namespace URI is an identifier that must be http
const element = document.createElementNS('http://www.w3.org/1999/xhtml', tagName)
if (tagName === 'a') {
Object.defineProperty(element, 'click', {
@ -151,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])
@ -180,7 +180,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
const earned24Hours = useMemo(() => sumEarned(allEntries, new Date(now - MILLISECONDS_IN_A_DAY)), [allEntries, now])
return (
<div className={cn('mx-auto flex-1 space-y-3', className)}>
<div className={cn('mx-auto min-w-0 flex-1 space-y-3', className)}>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner />
@ -188,7 +188,7 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
) : (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Stats row */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4">
{(
[
{ label: t('earn.report.stats.earned_total'), value: earnedTotal },
@ -212,23 +212,46 @@ export const EarnReportContent = ({ className, enabled }: EarnReportContentProps
<EarnReportChart entries={allEntries} />
{/* Toolbar: search + refresh */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder={t('earn.report.placeholder_search')}
value={globalFilter}
onChange={(event) => table.setGlobalFilter(event.target.value)}
className="pl-8"
/>
<div 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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', () => ({
@ -98,22 +101,22 @@ describe('AppSidebar', () => {
render(<AppSidebar side="left" />)
expect(screen.getByText('navbar.title')).toBeInTheDocument()
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('sidebar.title')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_home.label')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_receive')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_send')).toBeInTheDocument()
expect(screen.getByText('navbar.tab_earn')).toBeInTheDocument()
expect(screen.getByText('Earn Report')).toBeInTheDocument()
expect(screen.getByText('Orderbook')).toBeInTheDocument()
expect(screen.getByText('navbar.menu_mobile_settings')).toBeInTheDocument()
expect(screen.getByText('settings.rescan_chain')).toBeInTheDocument()
expect(screen.queryByText('settings.show_logs')).not.toBeInTheDocument()
expect(screen.getByText('sidebar.item_earn_report.label')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_orderbook.label')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_settings.label')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_rescan.label')).toBeInTheDocument()
expect(screen.queryByText('sidebar.item_logs.label')).not.toBeInTheDocument()
expect(screen.queryByText('Development')).not.toBeInTheDocument()
fireEvent.click(screen.getByTitle('global.close'))
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByText('Tour'))
fireEvent.click(screen.getByText('sidebar.item_tour.label'))
expect(mocks.toggleSidebar).toHaveBeenCalledTimes(2)
expect(onTour).toHaveBeenCalledTimes(1)
@ -128,7 +131,7 @@ describe('AppSidebar', () => {
render(<AppSidebar side="right" />)
expect(screen.getByText('settings.show_logs')).toBeInTheDocument()
expect(screen.getByText('sidebar.item_logs.label')).toBeInTheDocument()
expect(screen.getByText('Development')).toBeInTheDocument()
expect(screen.getByText('Dev Page')).toBeInTheDocument()
expect(screen.getByText('Dev Setup')).toBeInTheDocument()

View file

@ -6,6 +6,7 @@ import {
CurlyBracesIcon,
DownloadIcon,
HandCoinsIcon,
HistoryIcon,
LogsIcon,
MilkIcon,
NotebookTabsIcon,
@ -40,68 +41,80 @@ 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(
() => [
{
title: /*TODO: i18n t('sidebar.item_home.label')*/ 'Home',
title: t('sidebar.item_home.label'),
url: routes.home,
icon: WalletIcon,
},
{
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_receive'),
title: t('navbar.tab_receive'),
url: routes.receive,
icon: DownloadIcon,
},
{
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_send'),
title: t('navbar.tab_send'),
url: routes.send,
icon: UploadIcon,
},
{
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_earn'),
title: t('navbar.tab_earn'),
url: routes.earn,
icon: HandCoinsIcon,
subitems: [
{
title: /*TODO: i18n t('sidebar.item_earn_report.label')*/ 'Earn Report',
title: t('sidebar.item_earn_report.label'),
url: routes.earnReport,
icon: NotebookTabsIcon,
},
],
},
{
title: /*TODO: i18n t('sidebar.item_earn.label')*/ t('navbar.tab_sweep'),
title: t('navbar.tab_sweep'),
url: routes.sweep,
icon: BrushCleaningIcon,
},
{
title: /*TODO: i18n t('sidebar.item_orderbook.label')*/ 'Orderbook',
title: t('sidebar.item_orderbook.label'),
url: routes.orderbook,
icon: BookOpenIcon,
},
{
title: /*TODO: i18n t('sidebar.item_jars.label')*/ 'Jars',
title: t('sidebar.item_jars.label'),
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(
() => [
{
title: /*TODO: i18n t('sidebar.item_tour.label')*/ 'Tour',
title: t('sidebar.item_tour.label'),
url: routes.home,
icon: SparklesIcon,
onClick: () => {
@ -110,7 +123,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
},
},
{
title: /*TODO: i18n t('sidebar.item_rescan.label')*/ t('settings.rescan_chain'),
title: t('sidebar.item_rescan.label'),
url: routes.rescan,
icon: PackageSearchIcon,
},
@ -118,7 +131,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
? []
: [
{
title: /*TODO: i18n t('sidebar.item_settings.label')*/ t('settings.show_logs'),
title: t('sidebar.item_logs.label'),
url: routes.logs,
icon: LogsIcon,
},
@ -167,10 +180,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<Sidebar side={side} variant="sidebar" collapsible="offcanvas">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>
{/*TODO: i18n t('sidebar.title')*/}
{t('navbar.title')}
</SidebarGroupLabel>
<SidebarGroupLabel>{t('sidebar.title')}</SidebarGroupLabel>
<SidebarGroupAction className="cursor-pointer" title={t('global.close')} onClick={() => toggleSidebar()}>
<XIcon />
<span className="sr-only">{t('global.close')}</span>
@ -183,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 && (
@ -212,8 +223,7 @@ export function AppSidebar({ side }: Pick<React.ComponentProps<typeof Sidebar>,
<SidebarMenuButton asChild>
<Link to={routes.settings}>
<SettingsIcon />
{/*TODO: i18n t('sidebar.item_settings.label')*/}
<span>{t('navbar.menu_mobile_settings')}</span>
<span>{t('sidebar.item_settings.label')}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuSub>

View file

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

View file

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

View file

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

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